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.5.1")
implementation("dev.kdriver:core:0.5.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.5.1"
version = "0.5.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
96 changes: 69 additions & 27 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@
mouseMoveWithTrajectory(x, y)
}

override suspend fun mouseClick(

Check warning on line 226 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#L226

Function mouseClick has 4 return statements which exceeds the limit of 2. (detekt.ReturnCount)
button: Input.MouseButton,
modifiers: Int,
clickCount: Int,
Expand All @@ -231,33 +231,69 @@
// 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 scrollData = try {
apply<ScrollData?>(
jsFunction = """
function() {
if (!this || !this.isConnected) return null;

const rect = this.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;

// Check if element is visible in viewport
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const elementCenterY = rect.top + rect.height / 2;
const elementCenterX = rect.left + rect.width / 2;

// Calculate if we need to scroll
const needsScrollY = elementCenterY < 0 || elementCenterY > viewportHeight;
const needsScrollX = elementCenterX < 0 || elementCenterX > viewportWidth;

// Calculate scroll distances to center the element
const scrollY = needsScrollY ? elementCenterY - viewportHeight / 2 : 0;
const scrollX = needsScrollX ? elementCenterX - viewportWidth / 2 : 0;

return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
scrollX: scrollX,
scrollY: scrollY,
needsScroll: needsScrollY || needsScrollX
};
}
""".trimIndent()
)
} catch (e: EvaluateException) {
logger.warn("Could not get coordinates for $this: ${e.jsError}")
return
}

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

// Scroll element into view naturally if needed (P3 - Anti-detection)
if (scrollData.needsScroll) {
logger.debug("Scrolling by (${scrollData.scrollX}, ${scrollData.scrollY}) to bring $this into view")
tab.scrollTo(scrollData.scrollX, scrollData.scrollY)
}

// Get updated coordinates after scrolling
val coordinates = try {
apply<CoordinateResult?>(
jsFunction = """
function() {
if (!this || !this.isConnected) return null;

// Scroll element into view if not visible (P0 - CRITICAL FIX)
// This ensures mouseClick() works even when elements are off-viewport
this.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });

// Wait for scroll to complete using requestAnimationFrame
return new Promise(resolve => {
requestAnimationFrame(() => {
const rect = this.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
resolve(null);
} else {
resolve({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2
});
}
});
});
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(),
awaitPromise = true
""".trimIndent()
)
} catch (e: EvaluateException) {
logger.warn("Could not get coordinates for $this: ${e.jsError}")
Expand Down Expand Up @@ -360,12 +396,14 @@
focus()

// Set selection range to the beginning and get initial value length atomically
val initialLength = apply<Int>("""
val initialLength = apply<Int>(
"""
(el) => {
el.setSelectionRange(0, 0);
return el.value.length;
}
""".trimIndent()) ?: 0
""".trimIndent()
) ?: 0

// Delete each character using CDP Input.dispatchKeyEvent (P3 - Anti-detection)
// This generates isTrusted: true events unlike JavaScript KeyboardEvent dispatch
Expand All @@ -390,12 +428,14 @@
)

// Actually remove the character from the input value and get remaining length
remaining = apply<Int>("""
remaining = apply<Int>(
"""
(el) => {
el.value = el.value.slice(1);
return el.value.length;
}
""".trimIndent()) ?: 0
""".trimIndent()
) ?: 0

// Random delay between deletions (50-100ms) for natural variation
if (remaining > 0) {
Expand All @@ -404,12 +444,14 @@
}

// Dispatch input event to notify the page of the change
apply<String?>("""
apply<String?>(
"""
(el) => {
el.dispatchEvent(new Event('input', { bubbles: true }));
return null;
}
""".trimIndent())
""".trimIndent()
)
}

override suspend fun rawApply(
Expand Down
16 changes: 16 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package dev.kdriver.core.dom

import kotlinx.serialization.Serializable

/**
* Result from atomic scroll calculation operation.
* Used by mouseClick() to determine if scrolling is needed and by how much.
*/
@Serializable
data class ScrollData(
val x: Double,

Check warning on line 11 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt#L11

The property x is missing documentation. (detekt.UndocumentedPublicProperty)
val y: Double,

Check warning on line 12 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt#L12

The property y is missing documentation. (detekt.UndocumentedPublicProperty)
val scrollX: Double,

Check warning on line 13 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt#L13

The property scrollX is missing documentation. (detekt.UndocumentedPublicProperty)
val scrollY: Double,

Check warning on line 14 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt#L14

The property scrollY is missing documentation. (detekt.UndocumentedPublicProperty)
val needsScroll: Boolean,

Check warning on line 15 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ScrollData.kt#L15

The property needsScroll is missing documentation. (detekt.UndocumentedPublicProperty)
)
15 changes: 15 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package dev.kdriver.core.dom

import kotlinx.serialization.Serializable

/**
* Viewport dimensions and scroll position data.
* Used for calculating natural scroll gestures.
*/
@Serializable
data class ViewportData(
val width: Double,

Check warning on line 11 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt#L11

The property width is missing documentation. (detekt.UndocumentedPublicProperty)
val height: Double,

Check warning on line 12 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt#L12

The property height is missing documentation. (detekt.UndocumentedPublicProperty)
val scrollX: Double = 0.0,

Check warning on line 13 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt#L13

The property scrollX is missing documentation. (detekt.UndocumentedPublicProperty)
val scrollY: Double = 0.0,

Check warning on line 14 in core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/ViewportData.kt#L14

The property scrollY is missing documentation. (detekt.UndocumentedPublicProperty)
)
43 changes: 43 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.kdriver.core.tab

import dev.kdriver.cdp.CDPException
import dev.kdriver.cdp.Serialization
import dev.kdriver.cdp.domain.*
import dev.kdriver.cdp.domain.Input
import dev.kdriver.core.browser.Browser
Expand All @@ -22,6 +23,7 @@
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.decodeFromJsonElement
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.abs
Expand Down Expand Up @@ -216,6 +218,47 @@
delay((yDistance / speed).seconds)
}

override suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int?) {
if (scrollX == 0.0 && scrollY == 0.0) {
return
}

// Get current viewport dimensions for scroll origin
val viewportJson = rawEvaluate(
"""
({
width: window.innerWidth,
height: window.innerHeight
})
""".trimIndent()
)!!
val viewportData = Serialization.json.decodeFromJsonElement<dev.kdriver.core.dom.ViewportData>(viewportJson)

val originX = viewportData.width / 2
val originY = viewportData.height / 2

// Use provided speed or add natural variation (P3 - Anti-detection)
val scrollSpeed = speed ?: kotlin.random.Random.nextInt(600, 1200)

Check warning on line 241 in core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt#L241

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

Check warning on line 241 in core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt#L241

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

// Use negative distances because CDP's synthesizeScrollGesture uses inverted Y-axis
// (positive yDistance scrolls UP, but we want positive scrollY to scroll DOWN)
input.synthesizeScrollGesture(
x = originX,
y = originY,
xDistance = -scrollX, // Negative because positive xDistance scrolls left
yDistance = -scrollY, // Negative because positive yDistance scrolls up
speed = scrollSpeed,
preventFling = true,
gestureSourceType = Input.GestureSourceType.MOUSE
)

// Add a small delay for the scroll animation to complete (P3 - Anti-detection)
// Calculate duration based on distance and speed
val distance = kotlin.math.sqrt(scrollX * scrollX + scrollY * scrollY)
val duration = (distance / scrollSpeed * 1000).toLong() // Convert to milliseconds

Check warning on line 258 in core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt#L258

This expression contains a magic number. Consider defining it to a well named constant. (detekt.MagicNumber)
sleep(duration + kotlin.random.Random.nextLong(50, 150))

Check warning on line 259 in core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt#L259

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

Check warning on line 259 in core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/tab/DefaultTab.kt#L259

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

override suspend fun waitForReadyState(
until: ReadyState,
timeout: Long,
Expand Down
10 changes: 10 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,16 @@
*/
suspend fun scrollUp(amount: Int = 25, speed: Int = 800)

/**
* Scrolls the page naturally using CDP's synthesizeScrollGesture (P3 - Anti-detection).
* This creates smooth, human-like scrolling instead of instant jumps.
*
* @param scrollX The horizontal distance to scroll in pixels (positive scrolls right, negative scrolls left)
* @param scrollY The vertical distance to scroll in pixels (positive scrolls down, negative scrolls up)
* @param speed Swipe speed in pixels per second. If null, uses random variation between 600-1200 for natural behavior

Check warning on line 207 in core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/tab/Tab.kt#L207

Line detected, which is longer than the defined maximum line length in the code style. (detekt.MaxLineLength)
*/
suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int? = null)

/**
* Waits for the document's ready state to reach a specified state.
*
Expand Down
2 changes: 1 addition & 1 deletion docs/home/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ To install, add the dependency to your `build.gradle.kts`:

```kotlin
dependencies {
implementation("dev.kdriver:core:0.5.1")
implementation("dev.kdriver:core:0.5.2")
}
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,17 @@ class OpenTelemetryTab(
)
}

override suspend fun scrollTo(scrollX: Double, scrollY: Double, speed: Int?) =
tab.scrollTo(scrollX, scrollY, speed).also {
Span.current().addEvent(
"kdriver.tab.scrollTo", Attributes.builder()
.put("scrollX", scrollX)
.put("scrollY", scrollY)
.put("speed", speed?.toLong() ?: 0L)
.build()
)
}

override suspend fun waitForReadyState(
until: ReadyState,
timeout: Long,
Expand Down