From 8a57a492a81441962f6793bca13ae7cdbea2a526 Mon Sep 17 00:00:00 2001 From: Ramiro_quai Date: Sat, 12 Sep 2026 12:49:48 -0600 Subject: [PATCH 01/10] refactor(input): share pointer/mouse click dispatch across click paths Follows up on the maintainer's review note on PR #3431: WebDriver.click (testdriver), Frame.user_input.triggerMousePress/Release (CDP's Input.dispatchMouseEvent, i.e. Puppeteer/Playwright), and actions.click (MCP) each dispatched their own near-identical pointerdown/mousedown/ pointerup/mouseup/click sequence. Adds three shared functions to frame/user_input.zig -- dispatchPointerPress, dispatchPointerRelease, dispatchClickAsPointer -- and routes all three call sites through them. This gives the CDP path pointerdown/pointerup for the first time (it previously only fired bare mousedown/mouseup/click), and a PointerEvent click (previously a plain MouseEvent there). It also gives WebDriver.click suppress/focus handling it never had: that function used to dispatch its fixed five-event sequence unconditionally, ignoring preventDefault() and never moving focus. Because CDP's mousePressed and mouseReleased arrive as two independent Input.dispatchMouseEvent messages with no shared call stack, a new Page.input_mousedown_suppressed field carries whether the press half's cancelled pointerdown should suppress this gesture's mouseup on the release half. It's read-and-reset unconditionally at the top of triggerMouseRelease (and reset on a press that finds no element), so an unmatched or missed message can't leak stale state into the next gesture. dispatchPointerPress returns PressResult{suppress_mouse, suppress_focus} rather than running the focus default action itself: focusForMouseDown can fail, and the three callers don't agree on what that should mean for the click (actions.click: warn and continue; WebDriver.click and CDP: propagate), so each runs it against the result with its own handling. WebDriver.click also now reads frame._page.input_modifiers so a held modifier key still reaches its dispatched events, matching its own pre-existing local helpers' behavior (only compiled under -Dwpt_extensions; actions.click and CDP don't track modifier state, so they pass an empty Modifiers{}). WebDriver.actionSequence's performPointerSource (a fourth, more complex copy -- click counts, drag chords, touch) is deliberately left alone, as is its own pre-existing gap (no pointerdown-suppresses-mousedown there). Two new CDP tests reuse the existing mcp_actions.html fixture (#btn records the full event sequence; #btnPreventDefault's pointerdown listener calls preventDefault()) to pin the new pointer events and the cross-message suppression. Both were confirmed to fail against the pre-refactor triggerMousePress/Release bodies. Extended #btn's recorder with event.detail after an independent review caught mousedown/mouseup's click count silently dropping to 0 at all three call sites in an earlier version of this change; the MCP click test's assertion was updated to match. zig build test and zig build test -Dwpt_extensions: 1516/1516 both ways. zig fmt --check clean on all seven changed files. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XLXnBHBxQNskg2MAke3Lhv --- src/browser/Page.zig | 7 ++ src/browser/actions.zig | 73 +++++------------ src/browser/frame/user_input.zig | 127 +++++++++++++++++++++++++++-- src/browser/tests/mcp_actions.html | 2 +- src/browser/webapi/WebDriver.zig | 30 +++++-- src/mcp/tools.zig | 4 +- src/server/cdp/domains/input.zig | 96 ++++++++++++++++++++++ 7 files changed, 271 insertions(+), 68 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 045d0d93d..accd34861 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -186,6 +186,13 @@ 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, +// Whether the in-flight mouse gesture's pointerdown suppressed the +// compatibility mousedown/mouseup (preventDefault() was called). CDP's +// mousePressed/mouseReleased arrive as two independent +// Input.dispatchMouseEvent messages, so the release half reads this instead +// of carrying the state itself. +input_mousedown_suppressed: bool = false, + // 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..cf097ba40 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,12 @@ 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. +/// off pointerdown/mousedown, not click alone. Shares its dispatch mechanics +/// with `WebDriver.click` and the CDP mouse-press/release path (both go +/// through `Frame.user_input.dispatchPointer{Press,Release}`); this call +/// site's own error-handling choice — a focus failure is worth a warning, +/// not aborting the click — lives here, not in the shared code. pub fn click(node: *DOMNode, frame: *Frame) !void { const el = node.is(Element) orelse return error.InvalidNodeType; @@ -83,25 +53,26 @@ 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 }); - }; - } + const main = Frame.user_input.mouse_button.main; + const press_result = Frame.user_input.dispatchPointerPress(frame, el, 0, 0, main, 1, .{}) catch |err| { + lp.log.err(.app, "click press failed", .{ .err = err }); + return error.ActionFailed; + }; + if (!press_result.suppress_mouse and !press_result.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); - } + Frame.user_input.dispatchPointerRelease(frame, el, 0, 0, main, press_result.suppress_mouse, 1, .{}) catch |err| { + lp.log.err(.app, "click release failed", .{ .err = err }); + return error.ActionFailed; + }; - _ = try dispatchPointer(el, "click", 0, 1, frame); + Frame.user_input.dispatchClickAsPointer(frame, el, 0, 0, 1, .{}) catch |err| { + lp.log.err(.app, "click 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..d70377daa 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -184,7 +184,7 @@ fn dispatchBoundaryEvent(frame: *Frame, target: *Element, comptime mouse_typ: [] /// 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 { +fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, x: f64, y: f64, button: i32, buttons: u16, detail: u32, modifiers: Modifiers) !bool { const event: *MouseEvent = try .initTrusted(comptime .wrap(typ), .{ .bubbles = true, .cancelable = true, @@ -192,13 +192,109 @@ fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u .clientX = x, .clientY = y, .button = button, + .buttons = buttons, .detail = detail, + .ctrlKey = modifiers.ctrl, + .shiftKey = modifiers.shift, + .altKey = modifiers.alt, + .metaKey = modifiers.meta, }, frame); return frame._event_manager.dispatchCancelable(target.asEventTarget(), event.asEvent()); } +/// Dispatch a single trusted pointer event of the given type on `target`. +/// This engine only ever synthesizes mouse-sourced pointer input, so +/// pointerType is always "mouse" and pointerId/isPrimary are fixed. +fn dispatchPointerEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, x: f64, y: f64, button: i32, buttons: u16, detail: u32, modifiers: Modifiers) !bool { + const event: *PointerEvent = try .initTrusted(typ, .{ + .bubbles = true, + .cancelable = true, + .composed = true, + .clientX = x, + .clientY = y, + .button = button, + .buttons = buttons, + .detail = detail, + .pointerId = 1, + .pointerType = "mouse", + .isPrimary = true, + .pressure = if (buttons != 0) 0.5 else 0.0, + .ctrlKey = modifiers.ctrl, + .shiftKey = modifiers.shift, + .altKey = modifiers.alt, + .metaKey = 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 +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, + }; +} + +pub const PressResult = struct { + /// Whether pointerdown's preventDefault() suppressed the compatibility + /// mousedown fired here, and (the caller should record this) the + /// paired mouseup on release. + suppress_mouse: bool, + /// Whether mousedown's own preventDefault() suppressed just its focus + /// default action. Only meaningful when `suppress_mouse` is false — + /// mousedown itself never fires when it is true. + suppress_focus: bool, +}; + +/// The trusted press half of a click gesture, shared by `actions.click`, +/// `WebDriver.click`, and the CDP `Input.dispatchMouseEvent` (`mousePressed`) +/// path: pointerdown, then — unless its preventDefault() suppresses the +/// compatibility mouse event — mousedown. `mouse_detail` is mousedown's +/// click count (0 where the caller doesn't track one; pointerdown itself is +/// always detail 0, per spec). Does not itself run the focus default +/// action: `focusForMouseDown` can fail, and the three callers don't agree +/// on what a failure there should mean for the click, so each runs it (or +/// not) against `PressResult` with its own error handling. +pub fn dispatchPointerPress(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, mouse_detail: u32, modifiers: Modifiers) !PressResult { + const buttons = buttonsBitmask(button); + const suppress_mouse = try dispatchPointerEventOn(frame, target, "pointerdown", x, y, button, buttons, 0, modifiers); + const suppress_focus = if (suppress_mouse) + true + else + try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, mouse_detail, modifiers); + return .{ .suppress_mouse = suppress_mouse, .suppress_focus = suppress_focus }; +} + +/// The release half of a click gesture: pointerup, then mouseup (carrying +/// `mouse_detail`, its click count) unless the paired pointerdown suppressed +/// it (`suppress_mouse`, from `dispatchPointerPress`). Click still fires +/// even when suppressed — callers dispatch it separately via +/// `dispatchClickAsPointer`. +pub fn dispatchPointerRelease(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, suppress_mouse: bool, mouse_detail: u32, modifiers: Modifiers) !void { + _ = try dispatchPointerEventOn(frame, target, "pointerup", x, y, button, 0, 0, modifiers); + if (!suppress_mouse) { + _ = try dispatchMouseEventOn(frame, target, "mouseup", x, y, button, 0, mouse_detail, modifiers); + } +} + +/// The primary-button click, dispatched as a PointerEvent — matching how +/// the engine dispatches HTMLElement.click() and keyboard-triggered clicks. +pub fn dispatchClickAsPointer(frame: *Frame, target: *Element, x: f64, y: f64, detail: u32, modifiers: Modifiers) !void { + _ = try dispatchPointerEventOn(frame, target, "click", x, y, mouse_button.main, 0, detail, modifiers); +} + pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { - const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse return; + const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse { + // No gesture started on this page; don't leave a prior gesture's + // suppression outcome to be misread by the next mouseReleased. + frame._page.input_mousedown_suppressed = false; + return; + }; if (comptime lp.IS_DEBUG) { log.debug(.frame, "frame mouse press", .{ .url = frame.url, @@ -209,8 +305,15 @@ 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) { + // CDP's mousedown has never carried a click count (params.clickCount is + // only read on the release side); preserve that rather than fixing it + // here. mousePressed/mouseReleased arrive as two independent CDP + // messages, so the release half (below) can't observe this gesture's + // pointerdown directly — stash the outcome on the page first, before + // the fallible focus call, mirroring input_hover_target. + const press = try dispatchPointerPress(frame, target, x, y, button, 0, .{}); + frame._page.input_mousedown_suppressed = press.suppress_mouse; + if (!press.suppress_mouse and !press.suppress_focus) { try focusForMouseDown(frame, target); } } @@ -240,6 +343,12 @@ 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 { + // Consumed unconditionally, before any early return below, so a + // mouseReleased that misses every element (or a later error) can never + // leave this gesture's outcome to be misread by the next one. + const suppress_mouse = frame._page.input_mousedown_suppressed; + frame._page.input_mousedown_suppressed = false; + 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 +363,19 @@ 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); + try dispatchPointerRelease(frame, target, x, y, button, suppress_mouse, 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 dispatchClickAsPointer(frame, target, x, y, 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, y, button, 0, 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, y, button, 0, detail, .{}), + mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", x, y, button, 0, detail, .{}), else => {}, } } diff --git a/src/browser/tests/mcp_actions.html b/src/browser/tests/mcp_actions.html index 01f38ce6e..ef9c91a9f 100644 --- a/src/browser/tests/mcp_actions.html +++ b/src/browser/tests/mcp_actions.html @@ -34,7 +34,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 diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index da36d393e..980e65a53 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -67,11 +67,31 @@ 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); + // Shares its dispatch mechanics with actions.click and the CDP mouse + // press/release path (Frame.user_input.dispatchPointer{Press,Release}), + // which also gives this call its suppress/focus handling and its + // PointerEvent click for the first time — previously this dispatched a + // fixed five-event sequence unconditionally. Every dispatch here keeps + // this function's pre-existing contract of never failing: a dispatch + // error is a warning, not a rejection of the testdriver command. + const main = Frame.user_input.mouse_button.main; + const modifiers = frame._page.input_modifiers; + const press = Frame.user_input.dispatchPointerPress(frame, element, 0, 0, main, 1, modifiers) catch |err| { + log.warn(.app, "webdriver click press", .{ .err = err }); + return; + }; + if (!press.suppress_mouse and !press.suppress_focus) { + Frame.user_input.focusForMouseDown(frame, element) catch |err| { + log.warn(.app, "webdriver click focus", .{ .err = err }); + }; + } + Frame.user_input.dispatchPointerRelease(frame, element, 0, 0, main, press.suppress_mouse, 1, modifiers) catch |err| { + log.warn(.app, "webdriver click release", .{ .err = err }); + return; + }; + Frame.user_input.dispatchClickAsPointer(frame, element, 0, 0, 1, modifiers) catch |err| { + log.warn(.app, "webdriver click click", .{ .err = err }); + }; } const WebDriverCookie = struct { 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/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 05a30355c..1ed759818 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -596,6 +596,102 @@ test "cdp.input: dispatchMouseEvent right button fires contextmenu, double-click try testing.expect(result.isTrue()); } +// Regression for the shared click-dispatch refactor: mousePressed/ +// mouseReleased now go through the same Frame.user_input.dispatchPointer* +// primitives as actions.click and WebDriver.click, so CDP (i.e. Puppeteer, +// Playwright) gets pointerdown/pointerup for the first time. #btn's +// listeners (mcp_actions.html) record the same five-event sequence the MCP +// click test asserts on the actions.click path, modulo one pre-existing, +// deliberately-untouched CDP quirk: mousedown's detail (click count) has +// never been threaded from clickCount here, unlike mouseup/click below. +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:0::true', + \\ 'pointerup:0:0:0:mouse:true', 'mouseup:0:0:1::true', 'click:0:0:1:mouse:true' + \\]) + , null); + try testing.expect(result.isTrue()); +} + +// Regression for the same refactor's page-level suppression state +// (Page.input_mousedown_suppressed): mousePressed and mouseReleased are two +// independent CDP messages, so a pointerdown cancelled on the press half +// must still suppress mouseup on the release half, with no state carried by +// the caller between the two calls. +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()); +} + test "cdp.input: dispatchKeyEvent Tab runs sequential focus navigation" { var ctx = try testing.context(); defer ctx.deinit(); From d11293f328faf29ae546acccf0f9122eebd516b7 Mon Sep 17 00:00:00 2001 From: Ramiro_quai Date: Sun, 13 Sep 2026 13:16:55 -0600 Subject: [PATCH 02/10] refactor(input): trim refactor-narration comments to stated invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An automated review pass (Grok) on the shared click-dispatch diff flagged comments that narrated the refactor's history ("for the first time", "deliberately-untouched", "shares its dispatch mechanics with...") instead of stating the invariant a reader needs. Rewrote four: WebDriver.click's never-fails contract, actions.click's focus-error policy, the mousedown click-count comment (also fixed to attribute the gap to triggerMousePress's signature, not a CDP-only quirk — bidi/input.zig hits the same gap), and the two new CDP regression tests' descriptions. One suggested fix (clear input_mousedown_suppressed defensively before dispatchPointerPress) was reviewed and rejected: suppress_mouse=true is a constant once computed, with no fallible call between that assignment and return, so the flag can only be lost on a throw when suppress_mouse=false — which is the value already held by design. The scenario Grok describes additionally requires an unpaired mousePressed (protocol misuse), not a leak in this code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XLXnBHBxQNskg2MAke3Lhv --- src/browser/actions.zig | 7 ++----- src/browser/frame/user_input.zig | 12 ++++++------ src/browser/webapi/WebDriver.zig | 9 ++------- src/server/cdp/domains/input.zig | 21 ++++++++------------- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index cf097ba40..729c9eae5 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -39,11 +39,8 @@ fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void { } /// The trusted primary-button gesture a real user click produces; widgets key -/// off pointerdown/mousedown, not click alone. Shares its dispatch mechanics -/// with `WebDriver.click` and the CDP mouse-press/release path (both go -/// through `Frame.user_input.dispatchPointer{Press,Release}`); this call -/// site's own error-handling choice — a focus failure is worth a warning, -/// not aborting the click — lives here, not in the shared code. +/// off pointerdown/mousedown, not click alone. A focus failure is worth a +/// warning, not aborting the click. pub fn click(node: *DOMNode, frame: *Frame) !void { const el = node.is(Element) orelse return error.InvalidNodeType; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index d70377daa..b23bd677b 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -305,12 +305,12 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { .type = frame._type, }); } - // CDP's mousedown has never carried a click count (params.clickCount is - // only read on the release side); preserve that rather than fixing it - // here. mousePressed/mouseReleased arrive as two independent CDP - // messages, so the release half (below) can't observe this gesture's - // pointerdown directly — stash the outcome on the page first, before - // the fallible focus call, mirroring input_hover_target. + // triggerMousePress takes no click-count parameter, so mousedown always + // fires with detail 0; only the release side (below) carries a click + // count. mousePressed/mouseReleased arrive as two independent CDP + // messages, so the release half can't observe this gesture's pointerdown + // directly — stash the outcome on the page first, before the fallible + // focus call, mirroring input_hover_target. const press = try dispatchPointerPress(frame, target, x, y, button, 0, .{}); frame._page.input_mousedown_suppressed = press.suppress_mouse; if (!press.suppress_mouse and !press.suppress_focus) { diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 980e65a53..769d2ad0b 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -67,13 +67,8 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { } } - // Shares its dispatch mechanics with actions.click and the CDP mouse - // press/release path (Frame.user_input.dispatchPointer{Press,Release}), - // which also gives this call its suppress/focus handling and its - // PointerEvent click for the first time — previously this dispatched a - // fixed five-event sequence unconditionally. Every dispatch here keeps - // this function's pre-existing contract of never failing: a dispatch - // error is a warning, not a rejection of the testdriver command. + // A dispatch error must never reject the testdriver command, so each + // stage is caught and logged rather than propagated. const main = Frame.user_input.mouse_button.main; const modifiers = frame._page.input_modifiers; const press = Frame.user_input.dispatchPointerPress(frame, element, 0, 0, main, 1, modifiers) catch |err| { diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 1ed759818..ee0fd2994 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -596,14 +596,11 @@ test "cdp.input: dispatchMouseEvent right button fires contextmenu, double-click try testing.expect(result.isTrue()); } -// Regression for the shared click-dispatch refactor: mousePressed/ -// mouseReleased now go through the same Frame.user_input.dispatchPointer* -// primitives as actions.click and WebDriver.click, so CDP (i.e. Puppeteer, -// Playwright) gets pointerdown/pointerup for the first time. #btn's -// listeners (mcp_actions.html) record the same five-event sequence the MCP -// click test asserts on the actions.click path, modulo one pre-existing, -// deliberately-untouched CDP quirk: mousedown's detail (click count) has -// never been threaded from clickCount here, unlike mouseup/click below. +// A CDP mousePressed/mouseReleased pair fires the full pointerdown/mousedown/ +// pointerup/mouseup/click sequence, matching actions.click's five-event +// sequence (asserted in the MCP click test) except that mousedown's detail +// (click count) is always 0 — triggerMousePress takes no click-count +// parameter, so only the release half's mouseup/click carries one. test "cdp.input: dispatchMouseEvent mousePressed/mouseReleased fires the full pointer/mouse sequence" { var ctx = try testing.context(); defer ctx.deinit(); @@ -647,11 +644,9 @@ test "cdp.input: dispatchMouseEvent mousePressed/mouseReleased fires the full po try testing.expect(result.isTrue()); } -// Regression for the same refactor's page-level suppression state -// (Page.input_mousedown_suppressed): mousePressed and mouseReleased are two -// independent CDP messages, so a pointerdown cancelled on the press half -// must still suppress mouseup on the release half, with no state carried by -// the caller between the two calls. +// mousePressed and mouseReleased are two independent CDP messages, so a +// pointerdown cancelled on the press half must still suppress mouseup on the +// release half, with no state carried by the caller between the two calls. test "cdp.input: a cancelled pointerdown suppresses mousedown and mouseup across the split mousePressed/mouseReleased calls" { var ctx = try testing.context(); defer ctx.deinit(); From e0b919435070dd44c2bda82c709d9dd9db8774ff Mon Sep 17 00:00:00 2001 From: Ramiro_quai Date: Sun, 13 Sep 2026 15:48:04 -0600 Subject: [PATCH 03/10] fix(input): track a chorded button mask on the CDP/BiDi mouse path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent audit of the shared click-dispatch refactor (Codex, on b2cd5a190) reproduced a real defect: triggerMousePress/triggerMouseRelease treated every button press/release as its own complete gesture, with no notion of another button already held. Pressing a second button while the first was still down (a chord) fired a second pointerdown with the wrong buttons mask (single-button bitmask, not the aggregate), and Page.input_mousedown_suppressed — a bare bool — got overwritten by the second press's own suppression outcome, discarding whether the gesture's initiating pointerdown had actually been cancelled. The eventual release of the first (cancelled) button then incorrectly fired mouseup. Verified against real Chrome 152's behavior for the identical input (one pointerdown, button changes as pointermove, one final pointerup, no compatibility mousedown/mouseup once the initiating pointerdown is cancelled) per https://www.w3.org/TR/pointerevents3/#chorded-button-interactions. Fixed by adding Page.input_pressed_buttons (an aggregate mask) and dispatching pointerdown/pointerup only at its 0/nonzero transitions; a button change while another remains held fires pointermove instead, and input_mousedown_suppressed is now set once at gesture start and held for the whole chord rather than being overwritten per press. Scoped to triggerMousePress/triggerMouseRelease only — dispatchPointerPress/Release (used by actions.click and WebDriver.click, which can't chord) are unchanged. BiDi's input path calls the same two functions, so it gets the fix for free. One wrinkle caught while fixing: an existing test dispatches two mousePressed calls on the *same* button with no release between them (to test focus in isolation, not a real chord). Keyed the fresh-gesture check off "some *other* button already held" rather than "any button held" so that pattern still starts a fresh gesture, matching its own expectation. New test: "a mouse chord fires pointermove for the mid-gesture button change, not a second pointerdown/pointerup" on a new #btnChord fixture element (mcp_actions.html). Confirmed it fails against the pre-fix code (TestUnexpectedResult, verified by stashing the fix, running the test, and restoring) before trusting it. Verification: zig build test and zig build test -Dwpt_extensions: 1517/1517 both ways. zig fmt --check and git diff --check clean. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XLXnBHBxQNskg2MAke3Lhv --- src/browser/Page.zig | 18 +++++-- src/browser/frame/user_input.zig | 77 +++++++++++++++++++++++------- src/browser/tests/mcp_actions.html | 13 +++++ src/server/cdp/domains/input.zig | 57 ++++++++++++++++++++++ 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index accd34861..7fdb18abd 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -186,11 +186,19 @@ 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, -// Whether the in-flight mouse gesture's pointerdown suppressed the -// compatibility mousedown/mouseup (preventDefault() was called). CDP's -// mousePressed/mouseReleased arrive as two independent -// Input.dispatchMouseEvent messages, so the release half reads this instead -// of carrying the state itself. +// The aggregate mask of mouse buttons currently held down, tracked across +// individual mousePressed/mouseReleased (CDP) or equivalent (BiDi) messages +// so a chorded press (a second button pressed before the first is released) +// can be told apart from the start of a new gesture. +input_pressed_buttons: u16 = 0, + +// Whether the pointerdown that started the current gesture (the 0-to-nonzero +// transition of input_pressed_buttons) suppressed the compatibility +// mousedown/mouseup (preventDefault() was called). Set once at the start of +// the gesture and held for its whole duration, including any chorded +// button presses/releases in between — CDP's mousePressed/mouseReleased +// arrive as independent Input.dispatchMouseEvent messages, so each half +// reads this instead of carrying the state itself. input_mousedown_suppressed: bool = false, // Popup Frames opened by window.open. They are top-level browsing contexts diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index b23bd677b..21cdbcaf2 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -291,7 +291,8 @@ pub fn dispatchClickAsPointer(frame: *Frame, target: *Element, x: f64, y: f64, d pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse { // No gesture started on this page; don't leave a prior gesture's - // suppression outcome to be misread by the next mouseReleased. + // state to be misread by the next mousePressed/mouseReleased. + frame._page.input_pressed_buttons = 0; frame._page.input_mousedown_suppressed = false; return; }; @@ -305,16 +306,40 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { .type = frame._type, }); } - // triggerMousePress takes no click-count parameter, so mousedown always - // fires with detail 0; only the release side (below) carries a click - // count. mousePressed/mouseReleased arrive as two independent CDP - // messages, so the release half can't observe this gesture's pointerdown - // directly — stash the outcome on the page first, before the fallible - // focus call, mirroring input_hover_target. - const press = try dispatchPointerPress(frame, target, x, y, button, 0, .{}); - frame._page.input_mousedown_suppressed = press.suppress_mouse; - if (!press.suppress_mouse and !press.suppress_focus) { - try focusForMouseDown(frame, target); + + const button_bit = buttonsBitmask(button); + // Whether some *other* button was already down — the same button + // pressed again without a release in between (some callers do this + // deliberately to simulate two independent, unrelated presses) starts a + // fresh gesture rather than continuing a chord. + const other_already_held = frame._page.input_pressed_buttons & ~button_bit != 0; + frame._page.input_pressed_buttons |= button_bit; + const buttons = frame._page.input_pressed_buttons; + + if (!other_already_held) { + // Starts the gesture: full pointerdown, then (unless cancelled) + // mousedown and its focus default action. triggerMousePress takes + // no click-count parameter, so mousedown always fires with detail + // 0; only the release side (below) carries a click count. + // mousePressed/mouseReleased arrive as two independent CDP + // messages, so the release half can't observe this gesture's + // pointerdown directly — stash the outcome on the page first, + // before the fallible focus call, mirroring input_hover_target. + const press = try dispatchPointerPress(frame, target, x, y, button, 0, .{}); + frame._page.input_mousedown_suppressed = press.suppress_mouse; + if (!press.suppress_mouse and !press.suppress_focus) { + try focusForMouseDown(frame, target); + } + } else { + // A chorded press: another button going down while one is already + // held is a buttons-mask change on the same pointer, not a second + // pointerdown — https://www.w3.org/TR/pointerevents3/#chorded-button-interactions. + // The compatibility mousedown for this button still fires unless + // the gesture's own pointerdown was already cancelled. + _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, buttons, 0, .{}); + if (!frame._page.input_mousedown_suppressed) { + _ = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, 0, .{}); + } } } @@ -345,9 +370,14 @@ 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 { // Consumed unconditionally, before any early return below, so a // mouseReleased that misses every element (or a later error) can never - // leave this gesture's outcome to be misread by the next one. - const suppress_mouse = frame._page.input_mousedown_suppressed; - frame._page.input_mousedown_suppressed = false; + // leave this gesture's state to be misread by the next one. + const was_suppressed = frame._page.input_mousedown_suppressed; + const remaining = frame._page.input_pressed_buttons & ~buttonsBitmask(button); + frame._page.input_pressed_buttons = remaining; + const ends_gesture = remaining == 0; + if (ends_gesture) { + frame._page.input_mousedown_suppressed = false; + } const target = (try frame.window._document.elementFromPoint(x, y, frame)) orelse return; if (comptime lp.IS_DEBUG) { @@ -363,7 +393,18 @@ 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 dispatchPointerRelease(frame, target, x, y, button, suppress_mouse, detail, .{}); + if (ends_gesture) { + // The last held button releasing ends the gesture: full pointerup, + // then (unless suppressed) mouseup. + try dispatchPointerRelease(frame, target, x, y, button, was_suppressed, detail, .{}); + } else { + // A chorded release: another button remains held, so this is a + // buttons-mask change on the same pointer, not pointerup. + _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, remaining, 0, .{}); + if (!was_suppressed) { + _ = try dispatchMouseEventOn(frame, target, "mouseup", x, y, button, remaining, detail, .{}); + } + } // After mouseup, the activation event depends on the button. switch (button) { @@ -371,11 +412,11 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou try dispatchClickAsPointer(frame, target, x, y, detail, .{}); // A second click in quick succession also fires dblclick. if (click_count == 2) { - _ = try dispatchMouseEventOn(frame, target, "dblclick", x, y, button, 0, detail, .{}); + _ = try dispatchMouseEventOn(frame, target, "dblclick", x, y, button, remaining, detail, .{}); } }, - mouse_button.auxiliary => _ = try dispatchMouseEventOn(frame, target, "auxclick", x, y, button, 0, detail, .{}), - mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", x, y, button, 0, detail, .{}), + mouse_button.auxiliary => _ = try dispatchMouseEventOn(frame, target, "auxclick", x, y, button, remaining, detail, .{}), + mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", x, y, button, remaining, detail, .{}), else => {}, } } diff --git a/src/browser/tests/mcp_actions.html b/src/browser/tests/mcp_actions.html index ef9c91a9f..c6e409ce5 100644 --- a/src/browser/tests/mcp_actions.html +++ b/src/browser/tests/mcp_actions.html @@ -27,6 +27,7 @@ +
Focus me
Not focusable
@@ -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/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index ee0fd2994..6c6486401 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -687,6 +687,63 @@ test "cdp.input: a cancelled pointerdown suppresses mousedown and mouseup across try testing.expect(result.isTrue()); } +// A second button pressed while the first is still held is a chord on one +// pointer, not two independent gestures: the aggregate buttons mask changes +// (as pointermove), but pointerdown/pointerup fire only at the 0/nonzero +// transitions, and a cancelled pointerdown's mouse-event suppression holds +// for the whole gesture — https://www.w3.org/TR/pointerevents3/#chorded-button-interactions. +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()); +} + test "cdp.input: dispatchKeyEvent Tab runs sequential focus navigation" { var ctx = try testing.context(); defer ctx.deinit(); From 45e6cd47f951b538b53824f7de795995aff7064b Mon Sep 17 00:00:00 2001 From: Ramiro_quai Date: Sun, 13 Sep 2026 18:02:26 -0600 Subject: [PATCH 04/10] fix(input): run the mousedown focus default action on a chorded press too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second independent Codex audit on the rebased bdabfea42 found a real regression the chord fix itself introduced: the chorded-press branch in triggerMousePress dispatched the compatibility mousedown but discarded its cancellation result and never called focusForMouseDown, unlike the first-button path. Pressing a second button on a different element while the first is still held (e.g. right-click a second field while holding left on the first) left focus on the original element instead of moving it to the new mousedown's target, diverging from real Chrome. Independently verified before committing: - Traced the diff: the chorded branch's `_ = try dispatchMouseEventOn(...)` discarded the return value entirely, so suppress_focus was never computed and focusForMouseDown was never reachable from that branch — confirmed this matches the first-button path's own `if (!press.suppress_mouse and !press.suppress_focus) try focusForMouseDown(...)` structure, which the chord branch should mirror but didn't. - Reverted the one-line fix (kept the new test staged) and confirmed the new "chorded mousedown focuses its target unless pointerdown or mousedown was cancelled" test fails against the pre-fix code, then restored it. - Ran the full suite: zig build test and -Dwpt_extensions both 1521/1521. - Rebuilt the binary and ran the extended tools/shared-click-audit.mjs --assert-chord --assert-chord-focus against it: chord_focus_contract PASS, with the raw event trace confirming focus actually landed on the second element ("ticket"). - Ran the same harness with --chrome: chord_focus_contract PASS against real Chrome too, independently confirming this is the correct target behavior and not just the audit's claim. Fix mirrors the existing first-button path exactly: capture the chorded mousedown's own cancellation result and run focusForMouseDown when it wasn't cancelled, still gated by the gesture-level suppression flag so a cancelled initiating pointerdown still suppresses every mousedown in the chord, as before. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01XLXnBHBxQNskg2MAke3Lhv --- src/browser/frame/user_input.zig | 5 ++- src/server/cdp/domains/input.zig | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 21cdbcaf2..bf42159e4 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -338,7 +338,10 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { // the gesture's own pointerdown was already cancelled. _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, buttons, 0, .{}); if (!frame._page.input_mousedown_suppressed) { - _ = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, 0, .{}); + const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, 0, .{}); + if (!suppress_focus) { + try focusForMouseDown(frame, target); + } } } } diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 6c6486401..635e46b6e 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -744,6 +744,61 @@ test "cdp.input: a mouse chord fires pointermove for the mid-gesture button chan 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(); From 51a92dd0fd8f363e43458e793e15cf5859e13bfc Mon Sep 17 00:00:00 2001 From: Ramiro_quai Date: Mon, 14 Sep 2026 12:33:51 -0600 Subject: [PATCH 05/10] fix(input): address review on the CDP/chord click dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-1 review feedback from arrufat on #3507 (share pointer/mouse click dispatch across click paths): - CDP mousePressed now threads clickCount into mousedown's detail (mouse_detail), matching the MCP path (actions.click) instead of always firing detail 0. Threaded through BiDi's press call too, which was already tracking click_count for release but silently dropping it on press. - dispatchClickAsPointer now carries the still-held buttons mask instead of hardcoding 0, so a primary click firing mid-chord (the primary button releasing while another button is still held) reports the correct PointerEvent.buttons. - Fixed a regression caught while verifying the above against live Chrome: the first fix's fallback forced clickCount 0 (CDP's default when the field is omitted) to detail 1. Chrome and Firefox both preserve 0 there, and Chrome doesn't fire `click` at all in that case — the fallback now preserves 0 instead of forcing 1. Left two pre-existing issues alone, flagged in code comments instead of fixed, per arrufat's own scoping: - triggerMouseRelease's parallel clickCount-0 fallback has the same mismatch on the release side; not introduced by this PR. - The chord's activation-event ordering (contextmenu on the right press vs. this codebase's release-only contextmenu, and no auxclick) is a real, pre-existing deviation from both Chrome and Firefox, confirmed on Firefox too during this round, not Chrome-specific. Test coverage: a parametrized CDP test over clickCount 0/1/2 asserting mousedown's detail; a chord test asserting a mid-chord primary click's buttons mask. Two more tests were added independently during review audit and kept as-is: clickCount 2 on a press+release pair asserting detail 2 on mousedown/mouseup/click plus dblclick, and a chorded press asserting its own mousedown branch also carries the press message's clickCount. Confirmed against live Chrome (headless, raw CDP) and Firefox (headless, WebDriver BiDi) for both the mousedown-detail and chord-buttons fixes. zig fmt --check clean; cdp.input 20/20 and bidi.input 5/5 pass. Co-Authored-By: Claude Sonnet 5 --- src/browser/actions.zig | 2 +- src/browser/frame/user_input.zig | 31 +++-- src/browser/webapi/WebDriver.zig | 2 +- src/server/bidi/input.zig | 2 +- src/server/cdp/domains/input.zig | 219 ++++++++++++++++++++++++++++++- 5 files changed, 235 insertions(+), 21 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 729c9eae5..423b91a0c 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -66,7 +66,7 @@ pub fn click(node: *DOMNode, frame: *Frame) !void { return error.ActionFailed; }; - Frame.user_input.dispatchClickAsPointer(frame, el, 0, 0, 1, .{}) catch |err| { + Frame.user_input.dispatchClickAsPointer(frame, el, 0, 0, 1, 0, .{}) catch |err| { lp.log.err(.app, "click click failed", .{ .err = err }); return error.ActionFailed; }; diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index bf42159e4..73bbbca2e 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -284,11 +284,13 @@ pub fn dispatchPointerRelease(frame: *Frame, target: *Element, x: f64, y: f64, b /// The primary-button click, dispatched as a PointerEvent — matching how /// the engine dispatches HTMLElement.click() and keyboard-triggered clicks. -pub fn dispatchClickAsPointer(frame: *Frame, target: *Element, x: f64, y: f64, detail: u32, modifiers: Modifiers) !void { - _ = try dispatchPointerEventOn(frame, target, "click", x, y, mouse_button.main, 0, detail, modifiers); +/// `buttons` is the mask still held when the click fires (nonzero when the +/// primary button releases mid-chord, i.e. another button is still down). +pub fn dispatchClickAsPointer(frame: *Frame, target: *Element, x: f64, y: f64, detail: u32, buttons: u16, modifiers: Modifiers) !void { + _ = try dispatchPointerEventOn(frame, target, "click", x, y, mouse_button.main, buttons, detail, modifiers); } -pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { +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 { // No gesture started on this page; don't leave a prior gesture's // state to be misread by the next mousePressed/mouseReleased. @@ -315,17 +317,20 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { const other_already_held = frame._page.input_pressed_buttons & ~button_bit != 0; frame._page.input_pressed_buttons |= button_bit; const buttons = frame._page.input_pressed_buttons; + // A caller that doesn't track click counts (or a CDP message that omits + // clickCount) sends 0 — preserved as-is rather than forced to 1, since + // Chrome and Firefox both fire mousedown with detail 0 in that case. + const detail: u32 = if (click_count > 0) @intCast(click_count) else 0; if (!other_already_held) { // Starts the gesture: full pointerdown, then (unless cancelled) - // mousedown and its focus default action. triggerMousePress takes - // no click-count parameter, so mousedown always fires with detail - // 0; only the release side (below) carries a click count. - // mousePressed/mouseReleased arrive as two independent CDP - // messages, so the release half can't observe this gesture's - // pointerdown directly — stash the outcome on the page first, - // before the fallible focus call, mirroring input_hover_target. - const press = try dispatchPointerPress(frame, target, x, y, button, 0, .{}); + // mousedown (carrying click_count as its detail, matching Chrome and + // Firefox) and its focus default action. mousePressed/mouseReleased + // arrive as two independent CDP messages, so the release half can't + // observe this gesture's pointerdown directly — stash the outcome + // on the page first, before the fallible focus call, mirroring + // input_hover_target. + const press = try dispatchPointerPress(frame, target, x, y, button, detail, .{}); frame._page.input_mousedown_suppressed = press.suppress_mouse; if (!press.suppress_mouse and !press.suppress_focus) { try focusForMouseDown(frame, target); @@ -338,7 +343,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32) !void { // the gesture's own pointerdown was already cancelled. _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, buttons, 0, .{}); if (!frame._page.input_mousedown_suppressed) { - const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, 0, .{}); + const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, detail, .{}); if (!suppress_focus) { try focusForMouseDown(frame, target); } @@ -412,7 +417,7 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou // After mouseup, the activation event depends on the button. switch (button) { mouse_button.main => { - try dispatchClickAsPointer(frame, target, x, y, detail, .{}); + try dispatchClickAsPointer(frame, target, x, y, detail, remaining, .{}); // A second click in quick succession also fires dblclick. if (click_count == 2) { _ = try dispatchMouseEventOn(frame, target, "dblclick", x, y, button, remaining, detail, .{}); diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 769d2ad0b..129ea097f 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -84,7 +84,7 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { log.warn(.app, "webdriver click release", .{ .err = err }); return; }; - Frame.user_input.dispatchClickAsPointer(frame, element, 0, 0, 1, modifiers) catch |err| { + Frame.user_input.dispatchClickAsPointer(frame, element, 0, 0, 1, 0, modifiers) catch |err| { log.warn(.app, "webdriver click click", .{ .err = err }); }; } diff --git a/src/server/bidi/input.zig b/src/server/bidi/input.zig index b63cfef0a..6185a084a 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 635e46b6e..7503ae69e 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), @@ -598,9 +598,8 @@ test "cdp.input: dispatchMouseEvent right button fires contextmenu, double-click // A CDP mousePressed/mouseReleased pair fires the full pointerdown/mousedown/ // pointerup/mouseup/click sequence, matching actions.click's five-event -// sequence (asserted in the MCP click test) except that mousedown's detail -// (click count) is always 0 — triggerMousePress takes no click-count -// parameter, so only the release half's mouseup/click carries one. +// sequence (asserted in the MCP click test) — mousedown carries the press +// message's own clickCount as its detail, matching Chrome and Firefox. test "cdp.input: dispatchMouseEvent mousePressed/mouseReleased fires the full pointer/mouse sequence" { var ctx = try testing.context(); defer ctx.deinit(); @@ -637,13 +636,158 @@ test "cdp.input: dispatchMouseEvent mousePressed/mouseReleased fires the full po const result = try ls.local.compileAndRun( \\JSON.stringify(window.seq) === JSON.stringify([ - \\ 'pointerdown:0:1:0:mouse:true', 'mousedown:0:1:0::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' \\]) , null); try testing.expect(result.isTrue()); } +// clickCount 0 (the struct default, same as omitting the field entirely) is +// preserved as mousedown's detail rather than forced to 1 — Chrome and +// Firefox both fire detail 0 for an omitted/zero clickCount, distinct from +// the detail-1 case a plain single click sends. +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()); + } +} + +// Independent check that clickCount is not press-only: a clickCount-2 +// press+release pair must carry detail 2 on mousedown, mouseup, and click, +// and still fire dblclick from the release half. +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()); +} + +// The chorded-press branch has its own mousedown dispatch; clickCount must +// reach it too, not only the first-button dispatchPointerPress path. +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()); +} + // mousePressed and mouseReleased are two independent CDP messages, so a // pointerdown cancelled on the press half must still suppress mouseup on the // release half, with no state carried by the caller between the two calls. @@ -692,6 +836,12 @@ test "cdp.input: a cancelled pointerdown suppresses mousedown and mouseup across // (as pointermove), but pointerdown/pointerup fire only at the 0/nonzero // transitions, and a cancelled pointerdown's mouse-event suppression holds // for the whole gesture — https://www.w3.org/TR/pointerevents3/#chorded-button-interactions. +// This asserts only the pointer events this PR owns. The pointer/mouse +// events pin the same chord in real Chrome exactly; the *activation* order +// below (contextmenu on release rather than on the right press, and a +// trailing left click instead of auxclick) is a pre-existing, known +// deviation from Chrome's chorded release switch, not something this test +// claims is spec-correct. 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(); @@ -744,6 +894,65 @@ test "cdp.input: a mouse chord fires pointermove for the mid-gesture button chan try testing.expect(result.isTrue()); } +// A primary-button release mid-chord (another button still held) reports +// that held mask on its click, not 0 — the click is a PointerEvent and +// PointerEvent.buttons reflects buttons still down after this one lifts. +// Confirmed against real Chrome and Firefox: both report the held button on +// this click too (and, like this PR, fire it as a genuine `click`, not +// folding it into `auxclick`/`contextmenu`). +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(); From 6be87585c7ee2da51ef0f622f0f56bb16b4052e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 14 Sep 2026 21:12:14 +0200 Subject: [PATCH 06/10] webdriver: reuse buttonsBitmask from user_input --- src/browser/frame/user_input.zig | 6 ++---- src/browser/webapi/WebDriver.zig | 17 +++-------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 73bbbca2e..b2b66ad5c 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -229,7 +229,7 @@ fn dispatchPointerEventOn(frame: *Frame, target: *Element, comptime typ: []const /// MouseEvent/PointerEvent.buttons bitmask for a MouseEvent.button value. /// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons -fn buttonsBitmask(button: i32) u16 { +pub fn buttonsBitmask(button: i32) u16 { return switch (button) { mouse_button.main => 1, mouse_button.secondary => 2, @@ -316,7 +316,6 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count // fresh gesture rather than continuing a chord. const other_already_held = frame._page.input_pressed_buttons & ~button_bit != 0; frame._page.input_pressed_buttons |= button_bit; - const buttons = frame._page.input_pressed_buttons; // A caller that doesn't track click counts (or a CDP message that omits // clickCount) sends 0 — preserved as-is rather than forced to 1, since // Chrome and Firefox both fire mousedown with detail 0 in that case. @@ -341,6 +340,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count // pointerdown — https://www.w3.org/TR/pointerevents3/#chorded-button-interactions. // The compatibility mousedown for this button still fires unless // the gesture's own pointerdown was already cancelled. + const buttons = frame._page.input_pressed_buttons; _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, buttons, 0, .{}); if (!frame._page.input_mousedown_suppressed) { const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, detail, .{}); @@ -402,8 +402,6 @@ 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; if (ends_gesture) { - // The last held button releasing ends the gesture: full pointerup, - // then (unless suppressed) mouseup. try dispatchPointerRelease(frame, target, x, y, button, was_suppressed, detail, .{}); } else { // A chorded release: another button remains held, so this is a diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 129ea097f..8ec782653 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -296,18 +296,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 }); @@ -348,17 +348,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 { From 4ecd38ece8659ec22a8d34c58ff19f3d10d6ae8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 14 Sep 2026 21:25:00 +0200 Subject: [PATCH 07/10] input: PointerButtons gesture struct, shared focus helper, trim comments Fold the two loose Page fields (input_pressed_buttons, input_mousedown_suppressed) into a PointerButtons struct in user_input.zig that owns the chord mask and the press/release transitions, so triggerMousePress/Release keep only dispatch. Add runMouseDownFocus so the three click callers stop repeating the !suppress_mouse and !suppress_focus gate; a focus-error policy param keeps each caller's warn-vs-propagate behavior. Trim the verbose dispatch/trigger and CDP-test comments to a single sentence each, moving behavior contracts onto the function doc-comments; also drop the stale buttonsMask comment orphaned by the earlier buttonsBitmask reuse. --- src/browser/Page.zig | 17 +--- src/browser/actions.zig | 6 +- src/browser/frame/user_input.zig | 164 ++++++++++++++++--------------- src/browser/webapi/WebDriver.zig | 8 +- src/server/cdp/domains/input.zig | 47 +++------ 5 files changed, 102 insertions(+), 140 deletions(-) diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 7fdb18abd..60a764650 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -186,20 +186,9 @@ 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, -// The aggregate mask of mouse buttons currently held down, tracked across -// individual mousePressed/mouseReleased (CDP) or equivalent (BiDi) messages -// so a chorded press (a second button pressed before the first is released) -// can be told apart from the start of a new gesture. -input_pressed_buttons: u16 = 0, - -// Whether the pointerdown that started the current gesture (the 0-to-nonzero -// transition of input_pressed_buttons) suppressed the compatibility -// mousedown/mouseup (preventDefault() was called). Set once at the start of -// the gesture and held for its whole duration, including any chorded -// button presses/releases in between — CDP's mousePressed/mouseReleased -// arrive as independent Input.dispatchMouseEvent messages, so each half -// reads this instead of carrying the state itself. -input_mousedown_suppressed: bool = false, +// 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, diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 423b91a0c..722074cac 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -55,11 +55,7 @@ pub fn click(node: *DOMNode, frame: *Frame) !void { lp.log.err(.app, "click press failed", .{ .err = err }); return error.ActionFailed; }; - if (!press_result.suppress_mouse and !press_result.suppress_focus) { - Frame.user_input.focusForMouseDown(frame, el) catch |err| { - lp.log.warn(.app, "click mousedown focus", .{ .err = err }); - }; - } + try Frame.user_input.runMouseDownFocus(frame, el, press_result, "click mousedown focus"); Frame.user_input.dispatchPointerRelease(frame, el, 0, 0, main, press_result.suppress_mouse, 1, .{}) catch |err| { lp.log.err(.app, "click release failed", .{ .err = err }); diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index b2b66ad5c..3143b51f0 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -180,10 +180,7 @@ 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(). +/// Dispatch a trusted mouse event; returns whether preventDefault() cancelled it. fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u8, x: f64, y: f64, button: i32, buttons: u16, detail: u32, modifiers: Modifiers) !bool { const event: *MouseEvent = try .initTrusted(comptime .wrap(typ), .{ .bubbles = true, @@ -202,9 +199,8 @@ fn dispatchMouseEventOn(frame: *Frame, target: *Element, comptime typ: []const u return frame._event_manager.dispatchCancelable(target.asEventTarget(), event.asEvent()); } -/// Dispatch a single trusted pointer event of the given type on `target`. -/// This engine only ever synthesizes mouse-sourced pointer input, so -/// pointerType is always "mouse" and pointerId/isPrimary are fixed. +/// 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, x: f64, y: f64, button: i32, buttons: u16, detail: u32, modifiers: Modifiers) !bool { const event: *PointerEvent = try .initTrusted(typ, .{ .bubbles = true, @@ -240,26 +236,52 @@ pub fn buttonsBitmask(button: i32) u16 { }; } +/// 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.* = .{}; + } +}; + pub const PressResult = struct { - /// Whether pointerdown's preventDefault() suppressed the compatibility - /// mousedown fired here, and (the caller should record this) the - /// paired mouseup on release. + /// pointerdown's preventDefault() suppressed the compat mousedown here and + /// the paired mouseup on release. suppress_mouse: bool, - /// Whether mousedown's own preventDefault() suppressed just its focus - /// default action. Only meaningful when `suppress_mouse` is false — - /// mousedown itself never fires when it is true. + /// mousedown's preventDefault() suppressed its focus default action (only + /// meaningful when suppress_mouse is false). suppress_focus: bool, }; -/// The trusted press half of a click gesture, shared by `actions.click`, -/// `WebDriver.click`, and the CDP `Input.dispatchMouseEvent` (`mousePressed`) -/// path: pointerdown, then — unless its preventDefault() suppresses the -/// compatibility mouse event — mousedown. `mouse_detail` is mousedown's -/// click count (0 where the caller doesn't track one; pointerdown itself is -/// always detail 0, per spec). Does not itself run the focus default -/// action: `focusForMouseDown` can fail, and the three callers don't agree -/// on what a failure there should mean for the click, so each runs it (or -/// not) against `PressResult` with its own error handling. +/// Press half: pointerdown, then mousedown unless preventDefault() suppressed +/// it; doesn't run the focus default action (callers disagree on how a focus +/// failure should affect the click). pub fn dispatchPointerPress(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, mouse_detail: u32, modifiers: Modifiers) !PressResult { const buttons = buttonsBitmask(button); const suppress_mouse = try dispatchPointerEventOn(frame, target, "pointerdown", x, y, button, buttons, 0, modifiers); @@ -270,11 +292,19 @@ pub fn dispatchPointerPress(frame: *Frame, target: *Element, x: f64, y: f64, but return .{ .suppress_mouse = suppress_mouse, .suppress_focus = suppress_focus }; } -/// The release half of a click gesture: pointerup, then mouseup (carrying -/// `mouse_detail`, its click count) unless the paired pointerdown suppressed -/// it (`suppress_mouse`, from `dispatchPointerPress`). Click still fires -/// even when suppressed — callers dispatch it separately via -/// `dispatchClickAsPointer`. +/// Runs mousedown's focus default action unless the gesture suppressed it; +/// `warn_label` logs a focus failure and continues, null propagates it. +pub fn runMouseDownFocus(frame: *Frame, target: *Element, press: PressResult, comptime warn_label: ?[]const u8) !void { + if (press.suppress_mouse or press.suppress_focus) return; + if (warn_label) |label| { + focusForMouseDown(frame, target) catch |err| log.warn(.app, label, .{ .err = err }); + } else { + try focusForMouseDown(frame, target); + } +} + +/// Release half: pointerup, then mouseup unless the paired pointerdown +/// suppressed it (click fires separately via dispatchClickAsPointer). pub fn dispatchPointerRelease(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, suppress_mouse: bool, mouse_detail: u32, modifiers: Modifiers) !void { _ = try dispatchPointerEventOn(frame, target, "pointerup", x, y, button, 0, 0, modifiers); if (!suppress_mouse) { @@ -282,20 +312,16 @@ pub fn dispatchPointerRelease(frame: *Frame, target: *Element, x: f64, y: f64, b } } -/// The primary-button click, dispatched as a PointerEvent — matching how -/// the engine dispatches HTMLElement.click() and keyboard-triggered clicks. -/// `buttons` is the mask still held when the click fires (nonzero when the -/// primary button releases mid-chord, i.e. another button is still down). +/// Primary-button click as a PointerEvent (matching HTMLElement.click()); +/// `buttons` is the mask still held, nonzero on a mid-chord primary release. pub fn dispatchClickAsPointer(frame: *Frame, target: *Element, x: f64, y: f64, detail: u32, buttons: u16, modifiers: Modifiers) !void { _ = try dispatchPointerEventOn(frame, target, "click", x, y, mouse_button.main, buttons, detail, modifiers); } 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 { - // No gesture started on this page; don't leave a prior gesture's - // state to be misread by the next mousePressed/mouseReleased. - frame._page.input_pressed_buttons = 0; - frame._page.input_mousedown_suppressed = false; + // 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) { @@ -309,44 +335,24 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count }); } - const button_bit = buttonsBitmask(button); - // Whether some *other* button was already down — the same button - // pressed again without a release in between (some callers do this - // deliberately to simulate two independent, unrelated presses) starts a - // fresh gesture rather than continuing a chord. - const other_already_held = frame._page.input_pressed_buttons & ~button_bit != 0; - frame._page.input_pressed_buttons |= button_bit; - // A caller that doesn't track click counts (or a CDP message that omits - // clickCount) sends 0 — preserved as-is rather than forced to 1, since - // Chrome and Firefox both fire mousedown with detail 0 in that case. + 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; - if (!other_already_held) { - // Starts the gesture: full pointerdown, then (unless cancelled) - // mousedown (carrying click_count as its detail, matching Chrome and - // Firefox) and its focus default action. mousePressed/mouseReleased - // arrive as two independent CDP messages, so the release half can't - // observe this gesture's pointerdown directly — stash the outcome - // on the page first, before the fallible focus call, mirroring - // input_hover_target. + 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, x, y, button, detail, .{}); - frame._page.input_mousedown_suppressed = press.suppress_mouse; - if (!press.suppress_mouse and !press.suppress_focus) { - try focusForMouseDown(frame, target); - } + frame._page.input_pointer.mousedown_suppressed = press.suppress_mouse; + try runMouseDownFocus(frame, target, press, null); } else { - // A chorded press: another button going down while one is already - // held is a buttons-mask change on the same pointer, not a second - // pointerdown — https://www.w3.org/TR/pointerevents3/#chorded-button-interactions. - // The compatibility mousedown for this button still fires unless - // the gesture's own pointerdown was already cancelled. - const buttons = frame._page.input_pressed_buttons; - _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, buttons, 0, .{}); - if (!frame._page.input_mousedown_suppressed) { - const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, detail, .{}); - if (!suppress_focus) { - try focusForMouseDown(frame, target); - } + // 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, y, button, gesture.held, 0, .{}); + if (!frame._page.input_pointer.mousedown_suppressed) { + const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, gesture.held, detail, .{}); + try runMouseDownFocus(frame, target, .{ .suppress_mouse = false, .suppress_focus = suppress_focus }, null); } } } @@ -376,16 +382,12 @@ 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 { - // Consumed unconditionally, before any early return below, so a - // mouseReleased that misses every element (or a later error) can never - // leave this gesture's state to be misread by the next one. - const was_suppressed = frame._page.input_mousedown_suppressed; - const remaining = frame._page.input_pressed_buttons & ~buttonsBitmask(button); - frame._page.input_pressed_buttons = remaining; - const ends_gesture = remaining == 0; - if (ends_gesture) { - frame._page.input_mousedown_suppressed = false; - } + // 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) { @@ -404,8 +406,8 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou if (ends_gesture) { try dispatchPointerRelease(frame, target, x, y, button, was_suppressed, detail, .{}); } else { - // A chorded release: another button remains held, so this is a - // buttons-mask change on the same pointer, not pointerup. + // A chorded release (another button still held) is a buttons-mask + // change, not pointerup. _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, remaining, 0, .{}); if (!was_suppressed) { _ = try dispatchMouseEventOn(frame, target, "mouseup", x, y, button, remaining, detail, .{}); diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index 8ec782653..d318bc2e1 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -75,11 +75,7 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { log.warn(.app, "webdriver click press", .{ .err = err }); return; }; - if (!press.suppress_mouse and !press.suppress_focus) { - Frame.user_input.focusForMouseDown(frame, element) catch |err| { - log.warn(.app, "webdriver click focus", .{ .err = err }); - }; - } + try Frame.user_input.runMouseDownFocus(frame, element, press, "webdriver click focus"); Frame.user_input.dispatchPointerRelease(frame, element, 0, 0, main, press.suppress_mouse, 1, modifiers) catch |err| { log.warn(.app, "webdriver click release", .{ .err = err }); return; @@ -346,8 +342,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). // 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/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 7503ae69e..8fd444b41 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -596,10 +596,8 @@ test "cdp.input: dispatchMouseEvent right button fires contextmenu, double-click try testing.expect(result.isTrue()); } -// A CDP mousePressed/mouseReleased pair fires the full pointerdown/mousedown/ -// pointerup/mouseup/click sequence, matching actions.click's five-event -// sequence (asserted in the MCP click test) — mousedown carries the press -// message's own clickCount as its detail, matching Chrome and Firefox. +// 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(); @@ -643,10 +641,8 @@ test "cdp.input: dispatchMouseEvent mousePressed/mouseReleased fires the full po try testing.expect(result.isTrue()); } -// clickCount 0 (the struct default, same as omitting the field entirely) is -// preserved as mousedown's detail rather than forced to 1 — Chrome and -// Firefox both fire detail 0 for an omitted/zero clickCount, distinct from -// the detail-1 case a plain single click sends. +// 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 }, @@ -692,9 +688,8 @@ test "cdp.input: dispatchMouseEvent mousePressed's mousedown detail matches the } } -// Independent check that clickCount is not press-only: a clickCount-2 -// press+release pair must carry detail 2 on mousedown, mouseup, and click, -// and still fire dblclick from the release half. +// 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(); @@ -743,8 +738,7 @@ test "cdp.input: clickCount 2 on press and release puts detail 2 on mousedown, m try testing.expect(result.isTrue()); } -// The chorded-press branch has its own mousedown dispatch; clickCount must -// reach it too, not only the first-button dispatchPointerPress path. +// 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(); @@ -788,9 +782,8 @@ test "cdp.input: a chorded mousedown carries the press message's clickCount as i try testing.expect(result.isTrue()); } -// mousePressed and mouseReleased are two independent CDP messages, so a -// pointerdown cancelled on the press half must still suppress mouseup on the -// release half, with no state carried by the caller between the two calls. +// 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(); @@ -831,17 +824,9 @@ test "cdp.input: a cancelled pointerdown suppresses mousedown and mouseup across try testing.expect(result.isTrue()); } -// A second button pressed while the first is still held is a chord on one -// pointer, not two independent gestures: the aggregate buttons mask changes -// (as pointermove), but pointerdown/pointerup fire only at the 0/nonzero -// transitions, and a cancelled pointerdown's mouse-event suppression holds -// for the whole gesture — https://www.w3.org/TR/pointerevents3/#chorded-button-interactions. -// This asserts only the pointer events this PR owns. The pointer/mouse -// events pin the same chord in real Chrome exactly; the *activation* order -// below (contextmenu on release rather than on the right press, and a -// trailing left click instead of auxclick) is a pre-existing, known -// deviation from Chrome's chorded release switch, not something this test -// claims is spec-correct. +// 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(); @@ -894,12 +879,8 @@ test "cdp.input: a mouse chord fires pointermove for the mid-gesture button chan try testing.expect(result.isTrue()); } -// A primary-button release mid-chord (another button still held) reports -// that held mask on its click, not 0 — the click is a PointerEvent and -// PointerEvent.buttons reflects buttons still down after this one lifts. -// Confirmed against real Chrome and Firefox: both report the held button on -// this click too (and, like this PR, fire it as a genuine `click`, not -// folding it into `auxclick`/`contextmenu`). +// 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(); From 082b1bd15a6ff18a5744119b9ece515d4c01372b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 14 Sep 2026 21:37:44 +0200 Subject: [PATCH 08/10] refactor(input): bundle dispatcher params into PointerInput The two low-level trusted-event dispatchers took button (which button changed) and buttons (the held mask) as adjacent, swappable positional args. Bundle the shared event fields into a PointerInput struct with named fields, mirroring the file's HoverContext, so a transposition is a field name rather than a silent bug. --- src/browser/frame/user_input.zig | 77 ++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 3143b51f0..bcae9a1b0 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -180,45 +180,56 @@ fn dispatchBoundaryEvent(frame: *Frame, target: *Element, comptime mouse_typ: [] }; } +/// 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, x: f64, y: f64, button: i32, buttons: u16, detail: u32, modifiers: Modifiers) !bool { +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, - .buttons = buttons, - .detail = detail, - .ctrlKey = modifiers.ctrl, - .shiftKey = modifiers.shift, - .altKey = modifiers.alt, - .metaKey = modifiers.meta, + .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()); } /// 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, x: f64, y: f64, button: i32, buttons: u16, detail: u32, modifiers: Modifiers) !bool { +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 = x, - .clientY = y, - .button = button, - .buttons = buttons, - .detail = detail, + .clientX = in.x, + .clientY = in.y, + .button = in.button, + .buttons = in.buttons, + .detail = in.detail, .pointerId = 1, .pointerType = "mouse", .isPrimary = true, - .pressure = if (buttons != 0) 0.5 else 0.0, - .ctrlKey = modifiers.ctrl, - .shiftKey = modifiers.shift, - .altKey = modifiers.alt, - .metaKey = modifiers.meta, + .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()); } @@ -284,11 +295,11 @@ pub const PressResult = struct { /// failure should affect the click). pub fn dispatchPointerPress(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, mouse_detail: u32, modifiers: Modifiers) !PressResult { const buttons = buttonsBitmask(button); - const suppress_mouse = try dispatchPointerEventOn(frame, target, "pointerdown", x, y, button, buttons, 0, modifiers); + const suppress_mouse = try dispatchPointerEventOn(frame, target, "pointerdown", .{ .x = x, .y = y, .button = button, .buttons = buttons, .modifiers = modifiers }); const suppress_focus = if (suppress_mouse) true else - try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, buttons, mouse_detail, modifiers); + try dispatchMouseEventOn(frame, target, "mousedown", .{ .x = x, .y = y, .button = button, .buttons = buttons, .detail = mouse_detail, .modifiers = modifiers }); return .{ .suppress_mouse = suppress_mouse, .suppress_focus = suppress_focus }; } @@ -306,16 +317,16 @@ pub fn runMouseDownFocus(frame: *Frame, target: *Element, press: PressResult, co /// Release half: pointerup, then mouseup unless the paired pointerdown /// suppressed it (click fires separately via dispatchClickAsPointer). pub fn dispatchPointerRelease(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, suppress_mouse: bool, mouse_detail: u32, modifiers: Modifiers) !void { - _ = try dispatchPointerEventOn(frame, target, "pointerup", x, y, button, 0, 0, modifiers); + _ = try dispatchPointerEventOn(frame, target, "pointerup", .{ .x = x, .y = y, .button = button, .modifiers = modifiers }); if (!suppress_mouse) { - _ = try dispatchMouseEventOn(frame, target, "mouseup", x, y, button, 0, mouse_detail, modifiers); + _ = try dispatchMouseEventOn(frame, target, "mouseup", .{ .x = x, .y = y, .button = button, .detail = mouse_detail, .modifiers = modifiers }); } } /// Primary-button click as a PointerEvent (matching HTMLElement.click()); /// `buttons` is the mask still held, nonzero on a mid-chord primary release. pub fn dispatchClickAsPointer(frame: *Frame, target: *Element, x: f64, y: f64, detail: u32, buttons: u16, modifiers: Modifiers) !void { - _ = try dispatchPointerEventOn(frame, target, "click", x, y, mouse_button.main, buttons, detail, modifiers); + _ = try dispatchPointerEventOn(frame, target, "click", .{ .x = x, .y = y, .buttons = buttons, .detail = detail, .modifiers = modifiers }); } pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count: i32) !void { @@ -349,9 +360,9 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count } 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, y, button, gesture.held, 0, .{}); + _ = try dispatchPointerEventOn(frame, target, "pointermove", .{ .x = x, .y = y, .button = button, .buttons = gesture.held }); if (!frame._page.input_pointer.mousedown_suppressed) { - const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", x, y, button, gesture.held, detail, .{}); + const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", .{ .x = x, .y = y, .button = button, .buttons = gesture.held, .detail = detail }); try runMouseDownFocus(frame, target, .{ .suppress_mouse = false, .suppress_focus = suppress_focus }, null); } } @@ -408,9 +419,9 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou } else { // A chorded release (another button still held) is a buttons-mask // change, not pointerup. - _ = try dispatchPointerEventOn(frame, target, "pointermove", x, y, button, remaining, 0, .{}); + _ = try dispatchPointerEventOn(frame, target, "pointermove", .{ .x = x, .y = y, .button = button, .buttons = remaining }); if (!was_suppressed) { - _ = try dispatchMouseEventOn(frame, target, "mouseup", x, y, button, remaining, detail, .{}); + _ = try dispatchMouseEventOn(frame, target, "mouseup", .{ .x = x, .y = y, .button = button, .buttons = remaining, .detail = detail }); } } @@ -420,11 +431,11 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou try dispatchClickAsPointer(frame, target, x, y, detail, remaining, .{}); // A second click in quick succession also fires dblclick. if (click_count == 2) { - _ = try dispatchMouseEventOn(frame, target, "dblclick", x, y, button, remaining, 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, remaining, detail, .{}), - mouse_button.secondary => _ = try dispatchMouseEventOn(frame, target, "contextmenu", x, y, button, remaining, 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 => {}, } } From 063fc0ebb66bb34babadc20e71a6e831253f051d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 17 Sep 2026 07:36:32 +0800 Subject: [PATCH 09/10] dedupe code and use PointerInput --- src/browser/actions.zig | 19 +------ src/browser/frame/user_input.zig | 85 +++++++++++++++++--------------- src/browser/webapi/WebDriver.zig | 18 ++----- 3 files changed, 50 insertions(+), 72 deletions(-) diff --git a/src/browser/actions.zig b/src/browser/actions.zig index 722074cac..d40df49a9 100644 --- a/src/browser/actions.zig +++ b/src/browser/actions.zig @@ -38,9 +38,6 @@ fn dispatchInputAndChangeEvents(el: *Element, frame: *Frame) !void { }; } -/// The trusted primary-button gesture a real user click produces; widgets key -/// off pointerdown/mousedown, not click alone. A focus failure is worth a -/// warning, not aborting the click. pub fn click(node: *DOMNode, frame: *Frame) !void { const el = node.is(Element) orelse return error.InvalidNodeType; @@ -50,20 +47,8 @@ pub fn click(node: *DOMNode, frame: *Frame) !void { Frame.user_input.updateHoverTarget(frame, el, .{ .with_pointer = true }); - const main = Frame.user_input.mouse_button.main; - const press_result = Frame.user_input.dispatchPointerPress(frame, el, 0, 0, main, 1, .{}) catch |err| { - lp.log.err(.app, "click press failed", .{ .err = err }); - return error.ActionFailed; - }; - try Frame.user_input.runMouseDownFocus(frame, el, press_result, "click mousedown focus"); - - Frame.user_input.dispatchPointerRelease(frame, el, 0, 0, main, press_result.suppress_mouse, 1, .{}) catch |err| { - lp.log.err(.app, "click release failed", .{ .err = err }); - return error.ActionFailed; - }; - - Frame.user_input.dispatchClickAsPointer(frame, el, 0, 0, 1, 0, .{}) catch |err| { - lp.log.err(.app, "click click failed", .{ .err = err }); + Frame.user_input.triggerClick(frame, el, .{}) catch |err| { + lp.log.err(.app, "click failed", .{ .err = err }); return error.ActionFailed; }; } diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index bcae9a1b0..6c627cae0 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -281,52 +281,52 @@ pub const PointerButtons = struct { } }; -pub const PressResult = struct { +const PressResult = struct { /// pointerdown's preventDefault() suppressed the compat mousedown here and /// the paired mouseup on release. suppress_mouse: bool, - /// mousedown's preventDefault() suppressed its focus default action (only - /// meaningful when suppress_mouse is false). + /// mousedown's focus default action is suppressed (always when + /// suppress_mouse is). suppress_focus: bool, }; -/// Press half: pointerdown, then mousedown unless preventDefault() suppressed -/// it; doesn't run the focus default action (callers disagree on how a focus -/// failure should affect the click). -pub fn dispatchPointerPress(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, mouse_detail: u32, modifiers: Modifiers) !PressResult { - const buttons = buttonsBitmask(button); - const suppress_mouse = try dispatchPointerEventOn(frame, target, "pointerdown", .{ .x = x, .y = y, .button = button, .buttons = buttons, .modifiers = modifiers }); - const suppress_focus = if (suppress_mouse) - true - else - try dispatchMouseEventOn(frame, target, "mousedown", .{ .x = x, .y = y, .button = button, .buttons = buttons, .detail = mouse_detail, .modifiers = modifiers }); - return .{ .suppress_mouse = suppress_mouse, .suppress_focus = suppress_focus }; +/// 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 }; } -/// Runs mousedown's focus default action unless the gesture suppressed it; -/// `warn_label` logs a focus failure and continues, null propagates it. -pub fn runMouseDownFocus(frame: *Frame, target: *Element, press: PressResult, comptime warn_label: ?[]const u8) !void { - if (press.suppress_mouse or press.suppress_focus) return; - if (warn_label) |label| { - focusForMouseDown(frame, target) catch |err| log.warn(.app, label, .{ .err = err }); - } else { - try focusForMouseDown(frame, target); +/// 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); } } -/// Release half: pointerup, then mouseup unless the paired pointerdown -/// suppressed it (click fires separately via dispatchClickAsPointer). -pub fn dispatchPointerRelease(frame: *Frame, target: *Element, x: f64, y: f64, button: i32, suppress_mouse: bool, mouse_detail: u32, modifiers: Modifiers) !void { - _ = try dispatchPointerEventOn(frame, target, "pointerup", .{ .x = x, .y = y, .button = button, .modifiers = modifiers }); - if (!suppress_mouse) { - _ = try dispatchMouseEventOn(frame, target, "mouseup", .{ .x = x, .y = y, .button = button, .detail = mouse_detail, .modifiers = modifiers }); +/// 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 }); } -} -/// Primary-button click as a PointerEvent (matching HTMLElement.click()); -/// `buttons` is the mask still held, nonzero on a mid-chord primary release. -pub fn dispatchClickAsPointer(frame: *Frame, target: *Element, x: f64, y: f64, detail: u32, buttons: u16, modifiers: Modifiers) !void { - _ = try dispatchPointerEventOn(frame, target, "click", .{ .x = x, .y = y, .buttons = buttons, .detail = detail, .modifiers = modifiers }); + 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 { @@ -350,20 +350,25 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count // 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, x, y, button, detail, .{}); + const press = try dispatchPointerPress(frame, target, in); frame._page.input_pointer.mousedown_suppressed = press.suppress_mouse; - try runMouseDownFocus(frame, target, press, null); + 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) { - const suppress_focus = try dispatchMouseEventOn(frame, target, "mousedown", .{ .x = x, .y = y, .button = button, .buttons = gesture.held, .detail = detail }); - try runMouseDownFocus(frame, target, .{ .suppress_mouse = false, .suppress_focus = suppress_focus }, null); + 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); + } } } } @@ -415,7 +420,7 @@ 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; if (ends_gesture) { - try dispatchPointerRelease(frame, target, x, y, button, was_suppressed, detail, .{}); + 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. @@ -428,7 +433,7 @@ pub fn triggerMouseRelease(frame: *Frame, x: f64, y: f64, button: i32, click_cou // After mouseup, the activation event depends on the button. switch (button) { mouse_button.main => { - try dispatchClickAsPointer(frame, target, x, y, detail, remaining, .{}); + _ = 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 = x, .y = y, .button = button, .buttons = remaining, .detail = detail }); diff --git a/src/browser/webapi/WebDriver.zig b/src/browser/webapi/WebDriver.zig index d318bc2e1..2d2df2341 100644 --- a/src/browser/webapi/WebDriver.zig +++ b/src/browser/webapi/WebDriver.zig @@ -67,21 +67,9 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void { } } - // A dispatch error must never reject the testdriver command, so each - // stage is caught and logged rather than propagated. - const main = Frame.user_input.mouse_button.main; - const modifiers = frame._page.input_modifiers; - const press = Frame.user_input.dispatchPointerPress(frame, element, 0, 0, main, 1, modifiers) catch |err| { - log.warn(.app, "webdriver click press", .{ .err = err }); - return; - }; - try Frame.user_input.runMouseDownFocus(frame, element, press, "webdriver click focus"); - Frame.user_input.dispatchPointerRelease(frame, element, 0, 0, main, press.suppress_mouse, 1, modifiers) catch |err| { - log.warn(.app, "webdriver click release", .{ .err = err }); - return; - }; - Frame.user_input.dispatchClickAsPointer(frame, element, 0, 0, 1, 0, modifiers) catch |err| { - log.warn(.app, "webdriver click click", .{ .err = err }); + // 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 }); }; } From f65f737617133479b363bedfe4cf6e72831f5e29 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 17 Sep 2026 07:58:40 +0800 Subject: [PATCH 10/10] update for main changes --- src/browser/frame/user_input.zig | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 6c627cae0..bd39e063e 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -332,7 +332,7 @@ pub fn triggerClick(frame: *Frame, target: *Element, modifiers: Modifiers) !void 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(); + frame.page.input_pointer.reset(); return; }; if (comptime lp.IS_DEBUG) { @@ -346,7 +346,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count }); } - const gesture = frame._page.input_pointer.press(button); + 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; @@ -356,7 +356,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count // 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; + frame.page.input_pointer.mousedown_suppressed = press.suppress_mouse; if (press.suppress_focus == false) { try focusForMouseDown(frame, target); } @@ -364,7 +364,7 @@ pub fn triggerMousePress(frame: *Frame, x: f64, y: f64, button: i32, click_count // 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) { + 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); @@ -400,7 +400,7 @@ 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 gesture = frame.page.input_pointer.release(button); const remaining = gesture.held; const ends_gesture = gesture.ends_gesture; const was_suppressed = gesture.was_suppressed;