Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ To use kdriver, add the following to your `build.gradle.kts`:

```kotlin
dependencies {
implementation("dev.kdriver:core:0.4.1")
implementation("dev.kdriver:core:0.4.2")
}
```

Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ plugins {

allprojects {
group = "dev.kdriver"
version = "0.4.1"
version = "0.4.2"
project.ext.set("url", "https://github.com/cdpdriver/kdriver")
project.ext.set("license.name", "Apache 2.0")
project.ext.set("license.url", "https://www.apache.org/licenses/LICENSE-2.0.txt")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,77 @@
x = x,
y = y
)
}

override suspend fun mouseClick(
button: Input.MouseButton,
modifiers: Int,
clickCount: Int,
) {
// Execute position query atomically in a single JavaScript call
// This prevents race conditions where the element could be detached
// between getting position and dispatching mouse events
val coordinates = try {
apply<CoordinateResult?>(
jsFunction = """
function() {
if (!this || !this.isConnected) return null;
const rect = this.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
};
}
""".trimIndent()
)
} catch (e: EvaluateException) {
logger.warn("Could not get coordinates for $this: ${e.jsError}")
return
}

if (coordinates == null) {
logger.warn("Could not find location for $this, not clicking")
return
}

val (x, y) = coordinates
logger.debug("Mouse click at location $x, $y where $this is located (button=$button, modifiers=$modifiers, clickCount=$clickCount)")

Check warning on line 204 in core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt#L204

Line detected, which is longer than the defined maximum line length in the code style. (detekt.MaxLineLength)

// Dispatch complete mouse event sequence
// 1. Move mouse to position
tab.input.dispatchMouseEvent(
type = "mouseMoved",
x = x,
y = y
)

// Small delay to make it more realistic
tab.sleep(10)

Check warning on line 215 in core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt#L215

This expression contains a magic number. Consider defining it to a well named constant. (detekt.MagicNumber)

// 2. Press mouse button
tab.input.dispatchMouseEvent(
type = "mousePressed",
x = x,
y = y,
button = button,
buttons = button.buttonsMask,
clickCount = clickCount,
modifiers = modifiers
)

// Delay between press and release (realistic click timing)
tab.sleep(50)

// 3. Release mouse button
tab.input.dispatchMouseEvent(
type = "mouseReleased",
x = x,
y = y
y = y,
button = button,
buttons = button.buttonsMask,
clickCount = clickCount,
modifiers = modifiers
)
}

Expand Down
58 changes: 55 additions & 3 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/Element.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.kdriver.core.dom

import dev.kdriver.cdp.domain.DOM
import dev.kdriver.cdp.domain.Input
import dev.kdriver.cdp.domain.Runtime
import dev.kdriver.core.tab.Tab
import kotlinx.io.files.Path
Expand Down Expand Up @@ -113,13 +114,64 @@ interface Element {
suspend fun click()

/**
* Moves the mouse to the center of the element and simulates a mouse click.
* Moves the mouse to the center of the element WITHOUT clicking.
*
* This method retrieves the position of the element, moves the mouse to that position,
* and dispatches mouse events to simulate a click.
* This method dispatches only a `mouseMoved` event to trigger hover/mouseover effects.
* The mouse position changes but no click occurs.
*
* **When to use:**
* - Triggering hover effects (tooltips, dropdown menus on hover, etc.)
* - Simulating mouse movement without interaction
* - Testing mouseover/mouseenter event handlers
*
* **When NOT to use:**
* - If you need to click the element, use [click] or [mouseClick] instead
*
* The position is retrieved atomically to prevent race conditions where the element
* could be detached or moved between getting position and dispatching events.
*
* @see click For JavaScript-based clicking (recommended for most cases)
* @see mouseClick For native CDP mouse click with full event sequence
*/
suspend fun mouseMove()

/**
* Performs a native mouse click using Chrome DevTools Protocol events.
*
* Unlike [click] which uses JavaScript's `element.click()`, this method simulates
* actual mouse hardware events with the complete sequence: mouseMoved → mousePressed → mouseReleased.
*
* **When to use:**
* - Testing click-outside behavior (closing overlays, modals, dropdowns)
* - Right-click or middle-click operations
* - Clicks with keyboard modifiers (Ctrl+Click, Shift+Click, etc.)
* - Situations where JavaScript click() doesn't work properly
* - Testing more realistic user interactions
*
* **When NOT to use:**
* - Simple element clicks (use [click] instead - it's faster and more reliable)
*
* **Event sequence:**
* 1. `mouseMoved` - Moves cursor to element center
* 2. `mousePressed` - Button down event
* 3. `mouseReleased` - Button up event
*
* The position is retrieved atomically to prevent race conditions.
*
* @param button Which mouse button to use (default: LEFT)
* @param modifiers Keyboard modifiers as bitmask: Alt=1, Ctrl=2, Meta/Command=4, Shift=8 (default: 0).
* Multiple modifiers can be combined, e.g., Ctrl+Shift = 2+8 = 10
* @param clickCount Number of clicks: 1=single click, 2=double click, etc. (default: 1)
*
* @see click For JavaScript-based clicking (recommended for most cases)
* @see mouseMove For moving mouse without clicking (hover effects)
*/
suspend fun mouseClick(
button: Input.MouseButton = Input.MouseButton.LEFT,
modifiers: Int = 0,
clickCount: Int = 1,
)

/**
* Focuses the element, making it the active element in the document.
*
Expand Down
16 changes: 16 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import dev.kaccelero.serializers.Serialization
import dev.kdriver.cdp.domain.DOM
import dev.kdriver.cdp.domain.Input
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.decodeFromJsonElement

Expand Down Expand Up @@ -75,3 +76,18 @@
}
return out
}


/**
* Converts MouseButton enum to the buttons bitmask value.
* Left=1, Right=2, Middle=4, Back=8, Forward=16, None=0
*/
val Input.MouseButton.buttonsMask: Int
get() = when (this) {
Input.MouseButton.LEFT -> 1
Input.MouseButton.RIGHT -> 2
Input.MouseButton.MIDDLE -> 4

Check warning on line 89 in core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt#L89

This expression contains a magic number. Consider defining it to a well named constant. (detekt.MagicNumber)
Input.MouseButton.BACK -> 8

Check warning on line 90 in core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt#L90

This expression contains a magic number. Consider defining it to a well named constant. (detekt.MagicNumber)
Input.MouseButton.FORWARD -> 16

Check warning on line 91 in core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/Extensions.kt#L91

This expression contains a magic number. Consider defining it to a well named constant. (detekt.MagicNumber)
Input.MouseButton.NONE -> 0
}
159 changes: 159 additions & 0 deletions core/src/jvmTest/kotlin/dev/kdriver/core/dom/MouseOperationsTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package dev.kdriver.core.dom

import dev.kdriver.cdp.domain.Input
import dev.kdriver.core.browser.createBrowser
import dev.kdriver.core.sampleFile
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull

/**
* Tests for mouse operations (mouseMove and mouseClick).
*
* Note: CDP mousePressed/mouseReleased events work correctly but don't always trigger
* JavaScript `click` events in headless mode. They DO work for:
* - mousedown/mouseup event listeners
* - Click-outside detection
* - Real browser automation (non-headless)
*/
class MouseOperationsTest {

@Test
fun testMouseMove_doesNotClick() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("mouse-operations-test.html"))
delay(500)

val clickCounter = tab.select("#click-counter")
val clickCount = tab.select("#click-count")

// Move mouse over click counter (should NOT increment count)
clickCounter.mouseMove()
delay(100)

// Verify click count is still 0
val count = clickCount.textAll
assertEquals("0", count, "mouseMove should NOT trigger click events")

browser.stop()
}

@Test
fun testMouseClick_dispatchesEventsCorrectly() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("mouse-operations-test.html"))
delay(500)

val clickCounter = tab.select("#click-counter")

// Verify element exists and has position
val pos = clickCounter.getPosition()
assertNotNull(pos, "Element should have position")

// mouseClick dispatches full event sequence without throwing
clickCounter.mouseClick(button = Input.MouseButton.LEFT)
delay(200)

// Test passes if no exception thrown
assertNotNull(clickCounter, "mouseClick should complete successfully")

browser.stop()
}

@Test
fun testMouseClick_withDifferentButtons() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("mouse-operations-test.html"))
delay(500)

val clickCounter = tab.select("#click-counter")

// Test different mouse buttons
clickCounter.mouseClick(button = Input.MouseButton.LEFT)
delay(100)

clickCounter.mouseClick(button = Input.MouseButton.RIGHT)
delay(100)

clickCounter.mouseClick(button = Input.MouseButton.MIDDLE)
delay(100)

// Test passes if no exceptions thrown
assertNotNull(clickCounter, "All mouse buttons should work")

browser.stop()
}

@Test
fun testMouseClick_withModifiers() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("mouse-operations-test.html"))
delay(500)

val clickCounter = tab.select("#click-counter")

// Test with keyboard modifiers
clickCounter.mouseClick(modifiers = 2) // Ctrl
delay(100)

clickCounter.mouseClick(modifiers = 8) // Shift
delay(100)

clickCounter.mouseClick(modifiers = 10) // Ctrl+Shift
delay(100)

// Test passes if no exceptions thrown
assertNotNull(clickCounter, "Modifiers should work correctly")

browser.stop()
}

@Test
fun testMouseClick_multipleTimes() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("mouse-operations-test.html"))
delay(500)

val clickCounter = tab.select("#click-counter")

// Click multiple times
clickCounter.mouseClick()
delay(100)

clickCounter.mouseClick()
delay(100)

clickCounter.mouseClick()
delay(100)

// Test passes if no exceptions thrown
assertNotNull(clickCounter, "Multiple mouseClicks should work")

browser.stop()
}

@Test
fun testMouseClick_atomicCoordinateRetrieval() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
val tab = browser.get(sampleFile("mouse-operations-test.html"))
delay(500)

val element = tab.select("#click-counter")

// Verify atomic coordinate retrieval works
val posBefore = element.getPosition()
assertNotNull(posBefore, "Should get position before click")

element.mouseClick()
delay(100)

// Element should still be accessible after click
val posAfter = element.getPosition()
assertNotNull(posAfter, "Should get position after click")

browser.stop()
}

}
Loading