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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package dev.kdriver.core.dom

import kotlinx.serialization.Serializable

/**
* Result from atomic coordinate retrieval operation.
* Used by mouseMove() to get element center coordinates atomically.
*/
@Serializable
data class CoordinateResult(
val x: Double,

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

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/CoordinateResult.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/CoordinateResult.kt

View check run for this annotation

codefactor.io / CodeFactor

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

The property y is missing documentation. (detekt.UndocumentedPublicProperty)
)
157 changes: 122 additions & 35 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package dev.kdriver.core.dom
import dev.kdriver.cdp.domain.*
import dev.kdriver.core.exceptions.EvaluateException
import dev.kdriver.core.tab.Tab
import dev.kdriver.core.tab.evaluate
import dev.kdriver.core.utils.filterRecurse
import dev.kdriver.core.utils.filterRecurseAll
import io.ktor.util.logging.*
Expand Down Expand Up @@ -120,29 +119,48 @@ open class DefaultElement(
}

override suspend fun click() {
updateRemoteObject()
val objectId = remoteObject?.objectId ?: error("Could not resolve object id for $this")

val arguments = listOf(Runtime.CallArgument(objectId = objectId))

flash()
tab.runtime.callFunctionOn(
functionDeclaration = "(el) => el.click()",
objectId = objectId,
arguments = arguments,
awaitPromise = true,
userGesture = true,
returnByValue = true
apply<Unit>(
jsFunction = """
function() {
if (!this || !this.isConnected) {
throw new Error('Element is detached from DOM');
}
this.click();
}
""".trimIndent()
)
}

override suspend fun mouseMove() {
val position = getPosition()
if (position == null) {
// 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 moving mouse")
return
}
val (x, y) = position.center

val (x, y) = coordinates
logger.debug("Mouse move to location $x, $y where $this is located")

tab.input.dispatchMouseEvent(
Expand All @@ -159,7 +177,16 @@ open class DefaultElement(
}

override suspend fun focus() {
apply<Unit>("(elem) => elem.focus()")
apply<Unit>(
jsFunction = """
function() {
if (!this || !this.isConnected) {
throw new Error('Element is detached from DOM');
}
this.focus();
}
""".trimIndent()
)
}

override suspend fun sendKeys(text: String) {
Expand Down Expand Up @@ -223,8 +250,13 @@ open class DefaultElement(
awaitPromise: Boolean,
): JsonElement? {
val remoteObject = updateRemoteObject()

// Wrap user's function with connection validation
// This ensures the element is still connected before executing user code
val wrappedFunction = wrapSafe(jsFunction, validateVisible = false)

val result = tab.runtime.callFunctionOn(
functionDeclaration = jsFunction,
functionDeclaration = wrappedFunction,
objectId = remoteObject?.objectId,
arguments = listOf(
Runtime.CallArgument(objectId = remoteObject?.objectId)
Expand All @@ -248,25 +280,45 @@ open class DefaultElement(
}

override suspend fun getPosition(abs: Boolean): Position? {
updateRemoteObject()

// Execute everything atomically in a single JavaScript call
// This prevents race conditions where:
// 1. Element could detach between updateRemoteObject() and getContentQuads()
// 2. Element could move between getBoundingRect() and scroll position query
return try {
val quads = tab.dom.getContentQuads(objectId = remoteObject!!.objectId).quads
if (quads.isEmpty()) {
throw Exception("could not find position for $this")
}
val pos = Position(quads[0])
if (abs) {
val scrollY = tab.evaluate<Double>("window.scrollY") ?: 0.0
val scrollX = tab.evaluate<Double>("window.scrollX") ?: 0.0
val absX = pos.left + scrollX + pos.width / 2
val absY = pos.top + scrollY + pos.height / 2
pos.absX = absX
pos.absY = absY
val positionData = apply<PositionData?>(
jsFunction = """
function() {
if (!this || !this.isConnected) return null;
const rect = this.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
scrollX: ${if (abs) "window.scrollX" else "0"},
scrollY: ${if (abs) "window.scrollY" else "0"}
};
}
""".trimIndent()
) ?: return null

// Convert to Position object
val points = listOf(
positionData.left, positionData.top,
positionData.right, positionData.top,
positionData.right, positionData.bottom,
positionData.left, positionData.bottom
)

Position(points).also { pos ->
if (abs) {
pos.absX = positionData.left + positionData.scrollX + (positionData.right - positionData.left) / 2
pos.absY = positionData.top + positionData.scrollY + (positionData.bottom - positionData.top) / 2
}
}
pos
} catch (_: IndexOutOfBoundsException) {
logger.debug("no content quads for $this. mostly caused by element which is not 'in plain sight'")
} catch (e: EvaluateException) {
logger.debug("Could not get position for $this: ${e.jsError}")
null
}
}
Expand Down Expand Up @@ -302,4 +354,39 @@ open class DefaultElement(
else "<$tag>$content</$tag>"
}

/**
* Wraps a user JavaScript function with connection validation.
*
* @param userFunction The JavaScript function to wrap (can be arrow function or function declaration)
* @param validateVisible If true, also validates element visibility
* @return A wrapped function that validates element state before executing user code
*/
private fun wrapSafe(userFunction: String, validateVisible: Boolean = false): String {
val checks = buildString {
append(
"""
if (!this || !this.isConnected) {
throw new Error('Element is detached from DOM');
}
""".trimIndent()
)
if (validateVisible) append(
"""
const rect = this.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
throw new Error('Element is not visible');
}
""".trimIndent()
)
}

return """
function(elem) {
$checks
const userFn = $userFunction;
return userFn.call(elem, elem);
}
""".trimIndent()
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.kdriver.core.dom

import dev.kaccelero.serializers.Serialization
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.decodeFromJsonElement

/**
Expand All @@ -15,12 +16,14 @@
* @param jsFunction The JavaScript function to apply to the element.
* @param awaitPromise If true, waits for any promises to resolve before returning the result.
*
* @return The result of the function call, or null if the result is not serializable.
* @return The result of the function call, or null if the result is not serializable or JavaScript returns null.
*/
suspend inline fun <reified T> Element.apply(

Check warning on line 21 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#L21

Function apply has 3 return statements which exceeds the limit of 2. (detekt.ReturnCount)
jsFunction: String,
awaitPromise: Boolean = false,
): T? {
val raw = rawApply(jsFunction, awaitPromise) ?: return null
// If JavaScript returned null, return Kotlin null for nullable types
if (raw is JsonNull) return null
return Serialization.json.decodeFromJsonElement<T>(raw)
}
17 changes: 17 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/dom/PositionData.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package dev.kdriver.core.dom

import kotlinx.serialization.Serializable

/**
* Result from atomic position data retrieval operation.
* Used by getPosition() to get element bounds and scroll offsets atomically.
*/
@Serializable
data class PositionData(
val left: Double,

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

View check run for this annotation

codefactor.io / CodeFactor

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

The property left is missing documentation. (detekt.UndocumentedPublicProperty)
val top: Double,

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

View check run for this annotation

codefactor.io / CodeFactor

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

The property top is missing documentation. (detekt.UndocumentedPublicProperty)
val right: Double,

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

View check run for this annotation

codefactor.io / CodeFactor

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

The property right is missing documentation. (detekt.UndocumentedPublicProperty)
val bottom: Double,

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

View check run for this annotation

codefactor.io / CodeFactor

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

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

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

View check run for this annotation

codefactor.io / CodeFactor

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

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

Check warning on line 16 in core/src/commonMain/kotlin/dev/kdriver/core/dom/PositionData.kt

View check run for this annotation

codefactor.io / CodeFactor

core/src/commonMain/kotlin/dev/kdriver/core/dom/PositionData.kt#L16

The property scrollY is missing documentation. (detekt.UndocumentedPublicProperty)
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package dev.kdriver.core.browser

import dev.kdriver.cdp.domain.Target
import kotlin.test.Test
import kotlin.test.assertEquals

class BrowserTargetTest {

@Test
fun testBrowserTargetProperties() {
class TestBrowserTarget : BrowserTarget {
override var targetInfo: Target.TargetInfo? = Target.TargetInfo(
targetId = "target-123",
type = "page",
title = "Test Page",
url = "https://example.com",
attached = true,
openerId = "opener-456",
canAccessOpener = false,
openerFrameId = "frame-789",
browserContextId = "context-abc",
subtype = "prerender"
)
}

val target = TestBrowserTarget()
assertEquals("target-123", target.targetId)
assertEquals("page", target.type)
assertEquals("Test Page", target.title)
assertEquals("https://example.com", target.url)
assertEquals(true, target.attached)
assertEquals("opener-456", target.openerId)
assertEquals(false, target.canAccessOpener)
assertEquals("frame-789", target.openerFrameId)
assertEquals("context-abc", target.browserContextId)
assertEquals("prerender", target.subtype)
}

@Test
fun testBrowserTargetNullProperties() {
class TestBrowserTarget : BrowserTarget {
override var targetInfo: Target.TargetInfo? = null
}

val target = TestBrowserTarget()
assertEquals(null, target.targetId)
assertEquals(null, target.type)
assertEquals(null, target.title)
assertEquals(null, target.url)
assertEquals(null, target.attached)
assertEquals(null, target.openerId)
assertEquals(null, target.canAccessOpener)
assertEquals(null, target.openerFrameId)
assertEquals(null, target.browserContextId)
assertEquals(null, target.subtype)
}

}
66 changes: 66 additions & 0 deletions core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package dev.kdriver.core.browser

import dev.kdriver.core.sampleFile
import dev.kdriver.core.tab.ReadyState
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
Expand Down Expand Up @@ -85,4 +86,69 @@ class BrowserTest {
browser3.stop()
}

@Test
fun testBrowserWait() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)

val result = browser.wait(100)

assertNotNull(result)
browser.stop()
}

@Test
fun testGetWebsocketUrl() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)

val url = browser.websocketUrl

assertNotNull(url)
assertTrue(url.startsWith("ws://"))
browser.stop()
}

@Test
fun testGetTabs() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
browser.get(sampleFile("groceries.html"))

val tabs = browser.tabs

assertNotNull(tabs)
assertTrue(tabs.isNotEmpty())
browser.stop()
}

@Test
fun testGetTargets() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)

val targets = browser.targets

assertNotNull(targets)
browser.stop()
}

@Test
fun testUpdateTargets() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)
browser.get(sampleFile("groceries.html"))

browser.updateTargets()

assertTrue(browser.targets.isNotEmpty())
browser.stop()
}

@Test
fun testGetWithNewTab() = runBlocking {
val browser = createBrowser(this, headless = true, sandbox = false)

val initialTabCount = browser.tabs.size
browser.get(sampleFile("profile.html"), newTab = true)

assertTrue(browser.tabs.size > initialTabCount)
browser.stop()
}

}
Loading