Skip to content
Open
81 changes: 81 additions & 0 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8444,6 +8444,30 @@ declare module "bun" {
timeout?: number;
}

interface MouseButtonOptions {
/** @default "left" */
button?: "left" | "right" | "middle";
/** Modifier keys to hold during the event. */
modifiers?: Modifier[];
/** Click count for the event. @default 1 */
clickCount?: 1 | 2 | 3;
}

interface MouseMoveOptions {
/**
* Total number of `mousemove`/`pointermove` events dispatched from
* the current position to `(x, y)`, inclusive of the final event at
* the target. `1` = a single event at the target; larger values
* interpolate intermediate positions linearly. Drag handlers that
* observe `pointermove` at rAF rate want at least a handful;
* 5–20 is typical for smooth drags. Clamped to `[1, 1000]`.
* @default 1
*/
steps?: number;
/** Modifier keys to hold during the move. */
modifiers?: Modifier[];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

interface ScrollToOptions {
/**
* Maximum time in milliseconds to wait for the element to exist.
Expand Down Expand Up @@ -8861,6 +8885,63 @@ declare module "bun" {
*/
click(selector: string, options?: WebView.ClickSelectorOptions): Promise<void>;

/**
* Move the pointer to the given viewport coordinates.
*
* Dispatches `options.steps` `mousemove`/`pointermove` events from
* the current pointer position to `(x, y)`, including the final
* event at the target. If a button is currently held (from a prior
* {@link mouseDown}), the browser treats each event as part of a
* drag: on WebKit it fires as a native `mouseDragged` NSEvent; on
* Chrome it's still a `mousemove` but with a non-zero `buttons`
* field, so DOM drag handlers (listening for `pointermove` with
* `e.buttons !== 0`) see the intermediate positions. This is NOT
* the HTML5 Drag and Drop API — no `dragstart`/`dragover` events
* fire unless a `draggable` element initiates them page-side.
*
* The pointer position is tracked internally — call
* {@link mouseMove} before {@link mouseDown}/{@link mouseUp} to
* position the cursor.
*
* As a standalone hover (no prior {@link mouseDown}), moves cursor
* to `(x, y)`. On the Chrome backend this fires plain
* `mousemove`/`pointermove` events, so `:hover` styles apply and
* cursor-CSS assertions work. On the WebKit backend (macOS
* default) it currently only updates the internal cursor position
* without dispatching a DOM event — use the Chrome backend if you
* need `:hover` or `mousemove` assertions without a button held.
*
* @example
* ```ts
* // Canvas drag: mouseMove → mouseDown → mouseMove(steps: 5) → mouseUp.
* await view.mouseMove(from.x, from.y);
* await view.mouseDown();
* await view.mouseMove(to.x, to.y, { steps: 5 });
* await view.mouseUp();
* ```
*/
mouseMove(x: number, y: number, options?: WebView.MouseMoveOptions): Promise<void>;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Press a mouse button at the current pointer position.
*
* Uses the position last set by {@link mouseMove} (defaults to
* `(0, 0)` if never moved). Fires a `mousedown`/`pointerdown` event
* with `isTrusted: true` and holds the button down until
* {@link mouseUp}; between the two, {@link mouseMove} dispatches
* drag events.
*/
mouseDown(options?: WebView.MouseButtonOptions): Promise<void>;

/**
* Release a mouse button at the current pointer position.
*
* Fires `mouseup`/`pointerup`. If the press and release share the
* same position, the browser synthesizes a `click` event as well —
* same as manual interaction.
*/
mouseUp(options?: WebView.MouseButtonOptions): Promise<void>;

/**
* Insert text into the focused element.
*
Expand Down
105 changes: 105 additions & 0 deletions src/runtime/webview/ChromeBackend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1505,6 +1505,111 @@ JSPromise* click(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t b
.num("modifiers"_s, mods));
}

// Translate Bun's button-mask bitmap (bit 0=left, bit 1=right, bit 2=middle)
// to CDP's `buttons` field, which matches the W3C MouseEvent.buttons bit
// layout: bit 0=left, bit 1=right, bit 2=middle. Same layout. Kept as a
// helper for clarity and in case the layouts ever diverge.
static int32_t cdpButtonsMask(uint8_t mask) { return mask; }

// Low-level pointer primitives. mouseDown/mouseUp mirror click()'s single-
// event paths. mouseMove emits one dispatchMouseEvent per intermediate
// step plus the final — all but the last are fire-and-forget; the last
// resolves the slot. Chrome processes events in send order, so the final
// reply means all preceding events landed too.
JSPromise* mouseDown(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask)
{
auto& t = transport();
auto sid = sidSpan(view->m_sessionId);
auto btn = cdpButton(button);
int32_t mods = cdpModifiers(modifiers);
int32_t buttons = cdpButtonsMask(buttonsMask);

uint32_t id = t.nextId();
return sendChromeOp(g, view, view->m_pendingMisc, PendingSlot::Misc,
Method::InputDispatchMouseEvent, id,
Command(id, "Input.dispatchMouseEvent"_s, sid)
.raw("type"_s, "\"mousePressed\""_s)
.num("x"_s, x)
.num("y"_s, y)
.raw("button"_s, btn)
.num("buttons"_s, buttons)
.num("clickCount"_s, static_cast<int32_t>(clickCount))
.num("modifiers"_s, mods));
}

JSPromise* mouseUp(JSGlobalObject* g, JSWebView* view, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask)
{
auto& t = transport();
auto sid = sidSpan(view->m_sessionId);
auto btn = cdpButton(button);
int32_t mods = cdpModifiers(modifiers);
int32_t buttons = cdpButtonsMask(buttonsMask);

uint32_t id = t.nextId();
return sendChromeOp(g, view, view->m_pendingMisc, PendingSlot::Misc,
Method::InputDispatchMouseEvent, id,
Command(id, "Input.dispatchMouseEvent"_s, sid)
.raw("type"_s, "\"mouseReleased\""_s)
.num("x"_s, x)
.num("y"_s, y)
.raw("button"_s, btn)
.num("buttons"_s, buttons)
.num("clickCount"_s, static_cast<int32_t>(clickCount))
.num("modifiers"_s, mods));
}

// mouseMove: emit steps-1 intermediate events + 1 final. For pure hover
// (buttonsMask==0) the event is a plain mouseMoved with button:"none".
// When dragging (buttonsMask != 0) the event type is still "mouseMoved"
// — CDP doesn't have a separate "mouseDragged" — but the non-zero
// `buttons` field tells Chrome a drag is in progress, so page-side
// `mousemove` / `pointermove` handlers see `e.buttons !== 0`. This is
// NOT the HTML5 Drag and Drop API — no `dragenter`/`dragover` events
// fire unless a `draggable` element initiates them page-side (via the
// separate `Input.dispatchDragEvent` CDP method, not used here).
JSPromise* mouseMove(JSGlobalObject* g, JSWebView* view, float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers)
{
auto& t = transport();
auto sid = sidSpan(view->m_sessionId);
int32_t mods = cdpModifiers(modifiers);
int32_t buttons = cdpButtonsMask(buttonsMask);

// CDP mouseMoved with no pressed button wants "none"; with a button
// held it wants the string name of the primary button for legacy
// `MouseEvent.button` in handlers. Pick the lowest-order pressed bit.
ASCIILiteral btnStr = "\"none\""_s;
if (buttonsMask & 0x1)
btnStr = "\"left\""_s;
else if (buttonsMask & 0x2)
btnStr = "\"right\""_s;
else if (buttonsMask & 0x4)
btnStr = "\"middle\""_s;

if (steps < 1) steps = 1;

// Emit the first (steps - 1) events as fire-and-forget; send the
// final event with a tracked id that resolves the slot. Chrome
// processes serially so the final reply means all prior events
// were handled.
for (uint32_t i = 1; i < steps; ++i) {
float ix = fromX + (x - fromX) * (static_cast<float>(i) / static_cast<float>(steps));
float iy = fromY + (y - fromY) * (static_cast<float>(i) / static_cast<float>(steps));
uint32_t idInterm = t.nextId();
t.send(0, Command(idInterm, "Input.dispatchMouseEvent"_s, sid).raw("type"_s, "\"mouseMoved\""_s).num("x"_s, ix).num("y"_s, iy).raw("button"_s, btnStr).num("buttons"_s, buttons).num("modifiers"_s, mods));
}

uint32_t id = t.nextId();
return sendChromeOp(g, view, view->m_pendingMisc, PendingSlot::Misc,
Method::InputDispatchMouseEvent, id,
Command(id, "Input.dispatchMouseEvent"_s, sid)
.raw("type"_s, "\"mouseMoved\""_s)
.num("x"_s, x)
.num("y"_s, y)
.raw("button"_s, btnStr)
.num("buttons"_s, buttons)
.num("modifiers"_s, mods));
}

// Selector ops: Runtime.evaluate runs the rAF-polled actionability check
// (same predicate as WKWebView's kActionabilityJS). The IIFE takes
// (sel, timeout) — we appendQuotedJSONString the selector so any chars
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/webview/ChromeBackend.h
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,9 @@ JSC::JSPromise* evaluate(JSC::JSGlobalObject*, JSWebView*, const WTF::String& sc
JSC::JSPromise* screenshot(JSC::JSGlobalObject*, JSWebView*, ScreenshotFormat, uint8_t quality);
JSC::JSPromise* click(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount);
JSC::JSPromise* clickSelector(JSC::JSGlobalObject*, JSWebView*, const WTF::String& selector, uint32_t timeout, uint8_t button, uint8_t modifiers, uint8_t clickCount);
JSC::JSPromise* mouseDown(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask);
JSC::JSPromise* mouseUp(JSC::JSGlobalObject*, JSWebView*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount, uint8_t buttonsMask);
JSC::JSPromise* mouseMove(JSC::JSGlobalObject*, JSWebView*, float fromX, float fromY, float x, float y, uint32_t steps, uint8_t buttonsMask, uint8_t modifiers);
JSC::JSPromise* type(JSC::JSGlobalObject*, JSWebView*, const WTF::String& text);
JSC::JSPromise* press(JSC::JSGlobalObject*, JSWebView*, uint8_t virtualKey, uint8_t modifiers, const WTF::String& character);
JSC::JSPromise* scroll(JSC::JSGlobalObject*, JSWebView*, double dx, double dy);
Expand Down
77 changes: 77 additions & 0 deletions src/runtime/webview/JSWebView.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,83 @@ JSPromise* JSWebView::clickSelector(JSGlobalObject* g, const WTF::String& select
WK_DISPATCH(WK::Ops::clickSelector(g, this, selector, timeout, button, modifiers, clickCount));
}

// Low-level pointer primitives. Each backend dispatches one event (down/up)
// or a series (move with steps) and returns a promise that resolves when
// WebContent has processed the event(s).
//
// State (m_mouseButtons, m_mouseX, m_mouseY) is what the NEXT call will
// read, so it MUST reflect only successfully-sent events. Both backends'
// send paths return a synchronously-rejected promise when the transport
// is dead (WebKit host socket closed, Chrome pipe torn down, view closed
// mid-chain) — in that case leave state untouched so a caller who catches
// the rejection and opens a new view doesn't inherit a phantom held
// button. Async rejections (WebKit host crash after accept, CDP error
// reply) can't be reflected here without .then()-plumbing a continuation;
// the view is already unusable past that point — every subsequent op
// will reject — so the stale state is harmless. The in-flight
// m_pendingMisc slot serializes ops so no overlapping call reads
// half-updated state.
JSPromise* JSWebView::mouseDown(JSGlobalObject* g, uint8_t button, uint8_t modifiers, uint8_t clickCount)
{
uint8_t bit = 1u << button;
uint8_t newMask = m_mouseButtons | bit;
JSPromise* p;
if (m_backend == WebViewBackend::Chrome) {
p = CDP::Ops::mouseDown(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask);
} else {
#if OS(DARWIN)
p = WK::Ops::mouseDown(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask);
#else
RELEASE_ASSERT_NOT_REACHED();
p = nullptr;
#endif
}
if (p && p->status() != JSPromise::Status::Rejected)
m_mouseButtons = newMask;
return p;
}

JSPromise* JSWebView::mouseUp(JSGlobalObject* g, uint8_t button, uint8_t modifiers, uint8_t clickCount)
{
uint8_t bit = 1u << button;
uint8_t newMask = m_mouseButtons & ~bit;
JSPromise* p;
if (m_backend == WebViewBackend::Chrome) {
p = CDP::Ops::mouseUp(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask);
} else {
#if OS(DARWIN)
p = WK::Ops::mouseUp(g, this, m_mouseX, m_mouseY, button, modifiers, clickCount, newMask);
#else
RELEASE_ASSERT_NOT_REACHED();
p = nullptr;
#endif
}
if (p && p->status() != JSPromise::Status::Rejected)
m_mouseButtons = newMask;
return p;
}

JSPromise* JSWebView::mouseMove(JSGlobalObject* g, float x, float y, uint32_t steps, uint8_t modifiers)
{
float fromX = m_mouseX, fromY = m_mouseY;
JSPromise* p;
if (m_backend == WebViewBackend::Chrome) {
p = CDP::Ops::mouseMove(g, this, fromX, fromY, x, y, steps, m_mouseButtons, modifiers);
} else {
#if OS(DARWIN)
p = WK::Ops::mouseMove(g, this, fromX, fromY, x, y, steps, m_mouseButtons, modifiers);
#else
RELEASE_ASSERT_NOT_REACHED();
p = nullptr;
#endif
}
if (p && p->status() != JSPromise::Status::Rejected) {
m_mouseX = x;
m_mouseY = y;
}
return p;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

JSPromise* JSWebView::type(JSGlobalObject* g, const WTF::String& text)
{
if (m_backend == WebViewBackend::Chrome) return CDP::Ops::type(g, this, text);
Expand Down
20 changes: 20 additions & 0 deletions src/runtime/webview/JSWebView.h
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,18 @@ class JSWebView final : public WebCore::JSEventTarget {
ScreenshotFormat m_screenshotFormat = ScreenshotFormat::Png;
ScreenshotEncoding m_screenshotEncoding = ScreenshotEncoding::Blob;

// Pointer state for low-level drag primitives (mouseDown/Up/Move).
// Last cursor position from mouseMove, used as the default for
// mouseDown/mouseUp (Playwright-compatible: down/up take no coords).
// m_mouseButtons is a bitmask of currently-held buttons:
// bit 0 = left, bit 1 = right, bit 2 = middle.
// The mask determines whether a move dispatches a mouseMoved (no
// buttons) vs mouseDragged (any button) NSEvent, and which CDP
// `buttons` field we send.
float m_mouseX = 0.0f;
float m_mouseY = 0.0f;
uint8_t m_mouseButtons = 0;

JSC::WriteBarrier<JSC::JSObject> m_onNavigated;
JSC::WriteBarrier<JSC::JSObject> m_onNavigationFailed;
// Console capture. If the user passed `console: globalThis.console`,
Expand Down Expand Up @@ -166,6 +178,14 @@ class JSWebView final : public WebCore::JSEventTarget {
JSC::JSPromise* cdp(JSC::JSGlobalObject*, const WTF::String& method, const WTF::String& paramsJson);
JSC::JSPromise* click(JSC::JSGlobalObject*, float x, float y, uint8_t button, uint8_t modifiers, uint8_t clickCount);
JSC::JSPromise* clickSelector(JSC::JSGlobalObject*, const WTF::String& selector, uint32_t timeout, uint8_t button, uint8_t modifiers, uint8_t clickCount);
// Low-level pointer primitives. down/up use m_mouseX/Y (set by
// prior mouseMove — defaults to 0,0 if never moved). mouseMove
// interpolates steps intermediate points from m_mouseX/Y to x,y
// before updating m_mouseX/Y; with a button pressed (m_mouseButtons
// != 0) each intermediate dispatches as a mouseDragged event.
JSC::JSPromise* mouseDown(JSC::JSGlobalObject*, uint8_t button, uint8_t modifiers, uint8_t clickCount);
JSC::JSPromise* mouseUp(JSC::JSGlobalObject*, uint8_t button, uint8_t modifiers, uint8_t clickCount);
JSC::JSPromise* mouseMove(JSC::JSGlobalObject*, float x, float y, uint32_t steps, uint8_t modifiers);
JSC::JSPromise* type(JSC::JSGlobalObject*, const WTF::String& text);
JSC::JSPromise* press(JSC::JSGlobalObject*, WebViewProto::VirtualKey, uint8_t modifiers, const WTF::String& character);
JSC::JSPromise* scroll(JSC::JSGlobalObject*, double dx, double dy);
Expand Down
Loading
Loading