fix(actions): click dispatches full pointer/mouse sequence, not a bare click event - #3431
Conversation
…e click event actions.click() previously dispatched a single untrusted-shaped "click" MouseEvent directly on the target node, skipping pointerdown, mousedown, pointerup, and mouseup entirely. Many real-world widgets — custom autocomplete/combobox components in particular — open or otherwise react on mousedown, not click alone, so this made them unreachable through the click tool even though the element was correctly focused and targeted. WebDriver.zig's own click() already implements the correct sequence (pointerdown, mousedown, pointerup, mouseup, click) for testdriver's click, with a comment explicitly contrasting it against a lone untrusted click event. This change ports that same sequence into actions.click() so the MCP/CDP "click" action produces the same event sequence a real user click would. Adds a mousedown assertion to the existing MCP Actions test (mcp_actions.html + tools.zig) to catch a regression here; confirmed the new assertion fails against the old implementation and passes against this one. Full test suite (1339 tests) and zig fmt --check both pass. Reproduced against a real, previously-untested production site (a Wix-built autocomplete branch-selector widget) where click could focus the input but never open its option list; a minimal local reproduction (a mousedown-only widget) confirms the fix.
|
All contributors have signed the CLA ✍️ ✅ |
|
I have read the CLA Document and I hereby sign the CLA |
arrufat
left a comment
There was a problem hiding this comment.
Thanks, this is the right direction: the MCP click should produce the same gesture a real user does, and WebDriver.click is the right reference. A few things need to change before merging though, mostly guards that the WebDriver version has and this port drops.
Blocking
Disabled controls now activate. WebDriver.click (and HtmlElement.click in element/Html.zig) start with:
if (element.is(Element.Html)) |html| {
switch (html._type) {
.button, .input, .textarea, .select, .option, .optgroup => if (element.isDisabled()) return,
else => {},
}
}Without it, setChecked on a disabled checkbox toggles it and fires change, and a disabled button's mousedown handler runs. Please port the guard too.
A cancelled pointerdown must suppress mousedown/mouseup. Per Pointer Events, preventDefault() on pointerdown suppresses the compatibility mouse events (click still fires). Radix / Headless UI style triggers depend on this: they open on pointerdown, call preventDefault(), and close on a document-level mousedown. With this PR that combobox opens and immediately closes again. press() in the same file already shows the pattern: acquireRef() the event before dispatch, defer releaseRef, then check getDefaultPrevented() and skip the two mouse events when set.
mousedown has no focus default action. The CDP path (frame/user_input.zig, right after triggerMousePress dispatches mousedown) and WebDriver.actionSequence both call Frame.user_input.focusEditingHostForMouseDown. Here nothing does, so clicking a contenteditable editor or a div[tabindex=0] never focuses it and a following press goes to <body>.
Test coverage. The only new assertion is window.mousedowned === true. Dropping both pointer events, or reordering click before mouseup, keeps the test green. Please record the whole sequence in mcp_actions.html, e.g.
window.seq = [];
for (const t of ['pointerdown','mousedown','pointerup','mouseup','click'])
btn.addEventListener(t, e => window.seq.push([e.type, e.button, e.buttons, e.isTrusted].join(':')));and assert the exact five entries in tools.zig.
Should fix
- Hover step first. Both existing paths call
Frame.user_input.updateHoverTarget(frame, el, .{ .with_pointer = true })before pressing, sopointerover/mouseenterfire and the hover target is updated. Add it before the firstpointerdown. - Dispatch
clickas aPointerEvent. The engine already does this forHTMLElement.click()and keyboard clicks (pointerType: "mouse",pointerId: 1). With a plainMouseEvent,click.pointerTypeisundefinedwhilepointerdown.pointerTypeis"mouse"in the same gesture. pressure. Chrome reports0.5on a pressed mousepointerdown..pressure = if (buttons != 0) 0.5 else 0.0indispatchPointer.
Nits
button: i32is always0; drop the parameter.- Both helpers duplicate the dispatch/catch block and log
"click failed"for every event type. Onedispatch(el, event, comptime typ, frame)wrapper logging"click " ++ typ ++ " failed"is enough. .clientX = 0, .clientY = 0restate the defaults.- The comment above
clickrepeats the PR description. A single///line saying why (widgets key offmousedown/pointerdown, notclick) is enough, and the file iswebapi/WebDriver.zig, notWebDriver.zig.
Longer term this is now the third copy of the same sequence (WebDriver.click, user_input.triggerMousePress/Release, and this one), and they already disagree on the points above. Not asking for it in this PR, but a shared pub fn in frame/user_input.zig that all three call would be the real fix, and would also give Puppeteer/Playwright clicks pointerdown, which they currently lack.
Round-1 review feedback on the pointerdown/mousedown/pointerup/mouseup/ click sequence added in 3e8ff14: - Guard disabled elements up front (el.isDisabled()), matching the checks WebDriver.click and HtmlElement.click already have — otherwise a disabled checkbox/button still activates. - A cancelled pointerdown now suppresses both mousedown and mouseup for the gesture (one check, not two independent ones — see below). - mousedown now calls Frame.user_input.focusEditingHostForMouseDown, matching the CDP path in frame/user_input.zig, so clicking a contenteditable host or tabindex target actually focuses it. - updateHoverTarget(..., .{ .with_pointer = true }) fires before the first pointerdown, matching the hover step both other click paths take. - click is now dispatched as a PointerEvent (pointerType "mouse", pointerId 1), matching HTMLElement.click(), with pressure 0.5 while a button is down. - Dropped the always-0 `button` param from both dispatch helpers, merged the duplicated dispatch/log code into one dispatch() helper, dropped the restated clientX/clientY = 0 defaults, trimmed the doc comment. Test coverage: the click assertion in tools.zig now checks the exact five-event sequence (type:button:buttons:pointerType:isTrusted) instead of a single boolean. Two new fixtures in mcp_actions.html close the gaps the weaker test couldn't see: a button whose pointerdown listener calls preventDefault() (asserts the sequence collapses to exactly pointerdown, pointerup, click — no mousedown/mouseup), and a disabled button (asserts mousedown never fires). The first draft of the suppression fix wrongly gated mouseup on pointerup's own preventDefault() instead of pointerdown's; the new preventDefault fixture catches that regression too, confirmed by reintroducing it and watching the assertion fail before restoring the correct version. Full suite (1339 tests) and zig fmt --check pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
focusEditingHostForMouseDown only walked contenteditable hosts, so a div[tabindex=0] stayed unfocused after click. Replace it with focusForMouseDown: still prefer the outermost editing host, then focus the nearest mouse-focusable element (including tabindex=-1). Call sites: actions.click, CDP triggerMousePress, WebDriver pointerDown. Tests cover the MCP selector path, agent Page.click on the HTML fixture, and a runtime-created child click (ancestor walk, non-focusable must not steal focus, tabindex=-1 is mouse-focusable).
|
Thanks for the thorough review — addressed everything below. Disabled elements: Cancelled Mousedown focus: Hover, click as PointerEvent, pressure, and the nits: all done — Test coverage: the click assertion now pins the exact 5-event sequence On the "longer term" point — I went ahead and drafted that shared |
arrufat
left a comment
There was a problem hiding this comment.
Thanks, this round addresses everything from the first pass, and the event sequence now matches real browsers exactly. I drove mcp_actions.html through headless Chrome and Firefox with real (trusted) clicks; both agree on every point below, so the remaining items are places where the fixture asserts something a browser would fail.
Confirmed against Chrome + Firefox: the five-event sequence with button/buttons/pointerType/isTrusted, detail (0/1/0/1/1), pressure (0.5 while pressed), preventDefault() on pointerdown suppressing mousedown/mouseup and the focus change (both browsers leave focus where it was, so skipping focusForMouseDown in the suppressed branch is right), tabindex="-1" being mouse-focusable, and a click on a child focusing its tabindex ancestor.
Blocking
A mousedown outside any focusable element must blur the active element. In both browsers, clicking #focusTarget then #plain leaves document.activeElement === document.body; the fixture asserts focus stays on focusTarget (window.focusAfterPlain === 'focusTarget' in tools.zig, and the "plain click stole focus" check in Runtime.zig). That is the behaviour toolbars have to preventDefault() on mousedown to avoid, and dropdowns that close on blur rely on it. focusForMouseDown should end with:
// A mousedown outside any focusable element moves focus to the body.
const doc = target.asNode().ownerDocument(frame) orelse frame.document;
if (doc._active_element) |active| {
try active.blur(frame);
}and the fixture can record window.plainBlurred = document.activeElement === document.body instead. I ran the full suite with that change and the corrected assertions; nothing else moves, including the CDP path that now shares this helper.
An unparsable tabindex is not focusable. parseInteger(attr) orelse 0 makes <div tabindex="abc"> mouse-focusable; both browsers send focus to <body> for it. return Element.Html.parseInteger(attr) != null; is what you want.
Should fix
mouseFocusTabIndexreturns?i32but nobody reads the value; fold it intoisMouseFocusable(el) booldirectly (the two fixes above make the body a plainreturnchain anyway).- The
Runtime.zigtest "selector click preserves pointer mouse semantics" re-asserts exactly what thetools.zigtest does, over the sametools.zig→actions.clickpath. Drop it and keep "dynamic tabindex child click focuses ancestor", which covers new ground (runtime-created nodes, child → ancestor walk,tabindex=-1).
Nits
- Both browsers still dispatch
pointerdown/pointerupon a disabled button (no mouse events, noclick, no focus). The earlyreturnis fine for the tool if you'd rather keep it simple; if you want to be exact, treat disabled like the suppressed branch and skipclicktoo. // Dispatches a trusted pointer event…(actions.zig) and// Find the outermost element…(user_input.zig) describe functions, so they should be///doc comments like the others you added.
Addresses review on the mousedown default action. Blur when a mousedown lands outside any focusable element: focusForMouseDown now blurs the document's active element when the ancestor walk finds nothing mouse-focusable, so a plain click moves focus to the body. The MCP fixture had this backwards and asserted the old behavior; it now records document.activeElement === document.body. Treat an unparsable tabindex as if the attribute were absent (HTML 6.6.3) rather than as a terminal "not focusable" answer, so <button tabindex="abc"> keeps its native focusability. This mirrors HtmlElement.getTabIndex's parse-failure fallthrough for the same attribute instead of introducing a second parser with different semantics. Check the tabindex attribute before the HTML-only guard. An explicit, parseable tabindex is focusable on any element, and SVG links are focusable by the same href they activate on. This was only a gap until the blur above made it a regression: clicking such an element used to leave focus alone and would now have dropped it to the body, where Chrome focuses the element. Honor preventDefault() on mousedown. The default action ran unconditionally, so the toolbar idiom -- preventDefault() on mousedown to keep focus in a focused input -- did not work, and the new blur made that load-bearing. The mouse dispatch helpers in actions.zig, user_input.zig and WebDriver.zig now report whether the event was cancelled, and each call site gates focusForMouseDown on it. Non-mousedown call sites discard the result. Editing-host focus behavior is deliberately unchanged. Fold mouseFocusTabIndex into isMouseFocusable; its ?i32 return was never read. Drop the duplicate Runtime.zig test that drove the same actions.click path as the tools.zig fixture, and expand the remaining one, renamed to reflect what it now covers. Two of its assertions sample document.activeElement in a mouseup listener rather than after the click, because click activation behavior focuses those elements unconditionally and would mask what mousedown decided. Harden the disabled-control test to record the full five-event sequence instead of mousedown alone. Every assertion was checked to fail against the pre-fix code. zig build test 1340/1340, zig build test -Dwpt_extensions 1340/1340, zig fmt --check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — both blocking items are fixed, plus two things found while testing them. Blur on a mousedown outside any focusable element. Done as suggested. Unparsable if (el.getAttributeSafe(comptime .wrap("tabindex"))) |attr| {
if (Element.Html.parseInteger(attr)) |_| {
return true;
}
}
// falls through to the native-tag switch
Should-fix: The An explicit On the tests: two assertions sample
Separately, and not for this PR: while testing the blur I ran a set of trusted-input probes against Chrome 152 over CDP, and mouse focus inside editing hosts diverges from what we do in a few measured ways. The parts we already match: a host takes focus over a Worth flagging that the obvious rule doesn't work, since it cost me a detour: All of that is pre-existing and untouched here. If this round lands, I'm happy to send the full probe table and a follow-up PR implementing it, with a regression per case. Two other pre-existing items would fit naturally in the same change: 🤖 Generated with Claude Code |
Extract EventManager.dispatchCancelable so the acquire-ref/dispatch/read- defaultPrevented pattern lives in one place instead of four copies across actions.zig, user_input.zig and WebDriver.zig. Share a single isNativelyFocusable predicate between the mouse-focus rules and moveFocus, which had the same tag switch verbatim, and fold the SVG-link check into isSvgLink. Return early from Element.focus when the element is already active, before the visibility walk, so a click no longer pays a CSS ancestor walk to re-focus a native control. Collapse the repeated MCP click-test blocks into a selector loop and give the runtime focus test active()/expectActive() helpers.
…ll-mouse-sequence # Conflicts: # src/browser/frame/user_input.zig # src/browser/webapi/Element.zig
…im comments Route the mouse-wheel, keypress and press paths through the new EventManager.dispatchCancelable instead of open-coding the acquire-ref / dispatch / read-defaultPrevented dance three more times. Use getAttributeInterned for href/tabindex in the focusability helpers to match their neighbors, and drop WHAT-restating comments.
|
Thanks for the thorough back-and-forth on this, @R4m1r0qu41. The event sequence and the focus rules ended up in great shape, and I checked the fixture against Chrome and Firefox along the way. The branch had drifted a fair way behind
While I was in here I took the liberty of simplifying a bit:
I deliberately left the bigger structural items for the follow-up you offered rather than widening this PR: the shared click-sequence function, and unifying |
The agent-script tests only drive actions.click, so Input.dispatchMouseEvent's own gate had no coverage: deleting the `if (!suppressed)` in triggerMousePress left the whole suite green. Press on a preventDefault-ing element and assert focus is kept, then press a focusable one and assert focus moves, so the test pins the gate rather than an inert default action. Verified to fail when the gate is removed. The WebDriver path (performPointerSource) is still uncovered; WebDriver.zig has no unit tests and is exercised through WPT testdriver, so that one needs a harness rather than another test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`actions.click()` previously dispatched a single untrusted-shaped `click` `MouseEvent` directly on the target node, skipping `pointerdown`, `mousedown`, `pointerup`, and `mouseup` entirely. Many real-world widgets — custom autocomplete/combobox components in particular — open or otherwise react on `mousedown`, not `click` alone, so this made them unreachable through the click tool even though the element was correctly focused and targeted.
`WebDriver.zig`'s own `click()` already implements the correct sequence (`pointerdown`, `mousedown`, `pointerup`, `mouseup`, `click`) for testdriver's click, with a comment explicitly contrasting it against a lone untrusted click event. This change ports that same sequence into `actions.click()` so the MCP/CDP "click" action produces the same event sequence a real user click would.
Adds a `mousedown` assertion to the existing MCP Actions test (`mcp_actions.html` + `tools.zig`) to catch a regression here; confirmed the new assertion fails against the old implementation and passes against this one. Full test suite (1339 tests) and `zig fmt --check` both pass.
Reproduced against a real, previously-untested production site (a Wix-built autocomplete branch-selector widget) where `click` could focus the input but never open its option list; a minimal local reproduction (a mousedown-only widget) confirms the fix.