Skip to content

Commit ded41b3

Browse files
authored
fix: race conditions and other fixes (#57)
* fix: clear input missing events * fixes of other race conditions and issues
1 parent f5cb00c commit ded41b3

File tree

4 files changed

+240
-80
lines changed

4 files changed

+240
-80
lines changed

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

Lines changed: 211 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package dev.kdriver.core.dom
22

3+
import dev.kdriver.cdp.Serialization
34
import dev.kdriver.cdp.domain.*
45
import dev.kdriver.core.exceptions.EvaluateException
56
import dev.kdriver.core.tab.Tab
67
import io.ktor.util.logging.*
78
import kotlinx.io.files.Path
89
import kotlinx.serialization.json.JsonElement
10+
import kotlinx.serialization.json.decodeFromJsonElement
11+
import kotlin.random.Random
912

1013
/**
1114
* Default implementation of the [Element] interface.
@@ -20,12 +23,6 @@ open class DefaultElement(
2023

2124
private var remoteObject: Runtime.RemoteObject? = null
2225

23-
// Track last mouse position for natural trajectories (P2 - Anti-detection)
24-
companion object {
25-
private var lastMouseX: Double? = null
26-
private var lastMouseY: Double? = null
27-
}
28-
2926
override val tag: String
3027
get() = node.nodeName.lowercase()
3128

@@ -136,6 +133,76 @@ open class DefaultElement(
136133
)
137134
}
138135

136+
/**
137+
* Gets stable element coordinates by waiting for position to stabilize across multiple frames.
138+
* This prevents race conditions on slow systems where scroll or layout changes may still be in progress.
139+
*
140+
* @return Stable coordinates, or null if element is not visible/connected
141+
*/
142+
private suspend fun getStableCoordinates(): CoordinateResult? {
143+
return try {
144+
apply<CoordinateResult?>(
145+
jsFunction = """
146+
function() {
147+
if (!this || !this.isConnected) return null;
148+
149+
return new Promise(resolve => {
150+
let lastTop = null;
151+
let lastLeft = null;
152+
let stableFrames = 0;
153+
const maxAttempts = 10;
154+
let attempts = 0;
155+
156+
const checkStable = () => {
157+
attempts++;
158+
const rect = this.getBoundingClientRect();
159+
160+
if (rect.width === 0 || rect.height === 0) {
161+
resolve(null);
162+
return;
163+
}
164+
165+
if (lastTop !== null &&
166+
Math.abs(rect.top - lastTop) < 1 &&
167+
Math.abs(rect.left - lastLeft) < 1) {
168+
stableFrames++;
169+
if (stableFrames >= 2) {
170+
resolve({
171+
x: rect.left + rect.width / 2,
172+
y: rect.top + rect.height / 2
173+
});
174+
return;
175+
}
176+
} else {
177+
stableFrames = 0;
178+
}
179+
180+
lastTop = rect.top;
181+
lastLeft = rect.left;
182+
183+
if (attempts < maxAttempts) {
184+
requestAnimationFrame(checkStable);
185+
} else {
186+
// Timeout: use current position
187+
resolve({
188+
x: rect.left + rect.width / 2,
189+
y: rect.top + rect.height / 2
190+
});
191+
}
192+
};
193+
194+
requestAnimationFrame(checkStable);
195+
});
196+
}
197+
""".trimIndent(),
198+
awaitPromise = true
199+
)
200+
} catch (e: EvaluateException) {
201+
logger.warn("Could not get stable coordinates for $this: ${e.jsError}")
202+
null
203+
}
204+
}
205+
139206
/**
140207
* Moves the mouse to the target coordinates using a natural Bezier curve trajectory (P2 - Anti-detection).
141208
* This creates smooth, human-like mouse movements instead of instant teleportation.
@@ -144,20 +211,54 @@ open class DefaultElement(
144211
* @param targetY Target Y coordinate
145212
*/
146213
private suspend fun mouseMoveWithTrajectory(targetX: Double, targetY: Double) {
147-
val startX = lastMouseX ?: kotlin.random.Random.nextDouble(100.0, 400.0)
148-
val startY = lastMouseY ?: kotlin.random.Random.nextDouble(100.0, 300.0)
214+
val startX: Double
215+
val startY: Double
216+
217+
if (tab.lastMouseX != null && tab.lastMouseY != null) {
218+
startX = tab.lastMouseX!!
219+
startY = tab.lastMouseY!!
220+
} else {
221+
// Get actual viewport dimensions to avoid placing mouse outside visible area
222+
val viewportData = try {
223+
val viewportJson = tab.rawEvaluate(
224+
"""
225+
({
226+
width: window.innerWidth,
227+
height: window.innerHeight
228+
})
229+
""".trimIndent()
230+
)
231+
if (viewportJson != null) {
232+
Serialization.json.decodeFromJsonElement<dev.kdriver.core.dom.ViewportData>(viewportJson)
233+
} else null
234+
} catch (e: Exception) {
235+
null
236+
}
237+
238+
if (viewportData != null) {
239+
// Use random position within viewport bounds, with margins
240+
val maxX = (viewportData.width - 50).coerceAtLeast(100.0)
241+
val maxY = (viewportData.height - 50).coerceAtLeast(100.0)
242+
startX = Random.nextDouble(50.0, maxX)
243+
startY = Random.nextDouble(50.0, maxY)
244+
} else {
245+
// Fallback: use target coordinates with offset if viewport query fails
246+
startX = (targetX - Random.nextDouble(50.0, 150.0)).coerceAtLeast(0.0)
247+
startY = (targetY - Random.nextDouble(50.0, 150.0)).coerceAtLeast(0.0)
248+
}
249+
}
149250

150251
// Don't create trajectory if we're already at the target
151252
if (startX == targetX && startY == targetY) {
152253
return
153254
}
154255

155256
// Random number of steps for natural variation (8-15 steps)
156-
val steps = kotlin.random.Random.nextInt(8, 15)
257+
val steps = Random.nextInt(8, 15)
157258

158259
// Control point for quadratic Bezier curve with random offset
159-
val ctrlX = (startX + targetX) / 2 + kotlin.random.Random.nextDouble(-30.0, 30.0)
160-
val ctrlY = (startY + targetY) / 2 + kotlin.random.Random.nextDouble(-20.0, 20.0)
260+
val ctrlX = (startX + targetX) / 2 + Random.nextDouble(-30.0, 30.0)
261+
val ctrlY = (startY + targetY) / 2 + Random.nextDouble(-20.0, 20.0)
161262

162263
logger.debug("Mouse trajectory from ($startX, $startY) to ($targetX, $targetY) via control point ($ctrlX, $ctrlY) in $steps steps")
163264

@@ -171,14 +272,12 @@ open class DefaultElement(
171272
tab.input.dispatchMouseEvent(type = "mouseMoved", x = x, y = y)
172273

173274
// Random delay between steps for natural variation
174-
if (i < steps) {
175-
tab.sleep(kotlin.random.Random.nextLong(8, 25))
176-
}
275+
if (i < steps) tab.sleep(Random.nextLong(8, 25))
177276
}
178277

179278
// Update last position
180-
lastMouseX = targetX
181-
lastMouseY = targetY
279+
tab.lastMouseX = targetX
280+
tab.lastMouseY = targetY
182281
}
183282

184283
override suspend fun mouseMove() {
@@ -212,8 +311,8 @@ open class DefaultElement(
212311
val (centerX, centerY) = coordinates
213312

214313
// Add jitter to mouse coordinates (P1 - Anti-detection)
215-
val jitterX = (kotlin.random.Random.nextDouble() * 10 - 5) // -5 to +5 pixels
216-
val jitterY = (kotlin.random.Random.nextDouble() * 6 - 3) // -3 to +3 pixels
314+
val jitterX = (Random.nextDouble() * 10 - 5) // -5 to +5 pixels
315+
val jitterY = (Random.nextDouble() * 6 - 3) // -3 to +3 pixels
217316
val x = centerX + jitterX
218317
val y = centerY + jitterY
219318

@@ -280,25 +379,9 @@ open class DefaultElement(
280379
tab.scrollTo(scrollData.scrollX, scrollData.scrollY)
281380
}
282381

283-
// Get updated coordinates after scrolling
284-
val coordinates = try {
285-
apply<CoordinateResult?>(
286-
jsFunction = """
287-
function() {
288-
if (!this || !this.isConnected) return null;
289-
const rect = this.getBoundingClientRect();
290-
if (rect.width === 0 || rect.height === 0) return null;
291-
return {
292-
x: rect.left + rect.width / 2,
293-
y: rect.top + rect.height / 2
294-
};
295-
}
296-
""".trimIndent()
297-
)
298-
} catch (e: EvaluateException) {
299-
logger.warn("Could not get coordinates for $this: ${e.jsError}")
300-
return
301-
}
382+
// Get updated coordinates after scrolling, waiting for position stability
383+
// This is critical on slow systems where scroll may not complete immediately
384+
val coordinates = getStableCoordinates()
302385

303386
if (coordinates == null) {
304387
logger.warn("Could not find location for $this, not clicking")
@@ -309,8 +392,8 @@ open class DefaultElement(
309392

310393
// Add jitter to click coordinates (P1 - Anti-detection)
311394
// Humans don't click exactly at the mathematical center
312-
val jitterX = (kotlin.random.Random.nextDouble() * 10 - 5) // -5 to +5 pixels
313-
val jitterY = (kotlin.random.Random.nextDouble() * 6 - 3) // -3 to +3 pixels
395+
val jitterX = (Random.nextDouble() * 10 - 5) // -5 to +5 pixels
396+
val jitterY = (Random.nextDouble() * 6 - 3) // -3 to +3 pixels
314397
val x = centerX + jitterX
315398
val y = centerY + jitterY
316399

@@ -321,32 +404,92 @@ open class DefaultElement(
321404
mouseMoveWithTrajectory(x, y)
322405

323406
// Randomized delay to make it more realistic (P1 - Anti-detection)
324-
tab.sleep(kotlin.random.Random.nextLong(5, 20))
325-
326-
// 2. Press mouse button
327-
tab.input.dispatchMouseEvent(
328-
type = "mousePressed",
329-
x = x,
330-
y = y,
331-
button = button,
332-
buttons = button.buttonsMask,
333-
clickCount = clickCount,
334-
modifiers = modifiers
335-
)
407+
tab.sleep(Random.nextLong(5, 20))
336408

337-
// Randomized delay between press and release (P1 - Anti-detection)
338-
tab.sleep(kotlin.random.Random.nextLong(40, 120))
339-
340-
// 3. Release mouse button
341-
tab.input.dispatchMouseEvent(
342-
type = "mouseReleased",
343-
x = x,
344-
y = y,
345-
button = button,
346-
buttons = button.buttonsMask,
347-
clickCount = clickCount,
348-
modifiers = modifiers
349-
)
409+
// 2. Verify element hasn't moved during trajectory (handles React re-renders, animations, etc.)
410+
val finalCoordinates = try {
411+
apply<CoordinateResult?>(
412+
jsFunction = """
413+
function() {
414+
if (!this || !this.isConnected) return null;
415+
const rect = this.getBoundingClientRect();
416+
if (rect.width === 0 || rect.height === 0) return null;
417+
return {
418+
x: rect.left + rect.width / 2,
419+
y: rect.top + rect.height / 2
420+
};
421+
}
422+
""".trimIndent()
423+
)
424+
} catch (e: EvaluateException) {
425+
null
426+
}
427+
428+
// Adjust click position if element moved significantly (>5px threshold)
429+
val finalX: Double
430+
val finalY: Double
431+
if (finalCoordinates != null) {
432+
val (finalCenterX, finalCenterY) = finalCoordinates
433+
val deltaX = finalCenterX - centerX
434+
val deltaY = finalCenterY - centerY
435+
val moved = kotlin.math.sqrt(deltaX * deltaX + deltaY * deltaY) > 5.0
436+
437+
if (moved) {
438+
logger.debug("Element moved during trajectory by ($deltaX, $deltaY), adjusting click position")
439+
finalX = finalCenterX + jitterX
440+
finalY = finalCenterY + jitterY
441+
// Move mouse to adjusted position
442+
tab.input.dispatchMouseEvent(type = "mouseMoved", x = finalX, y = finalY)
443+
tab.lastMouseX = finalX
444+
tab.lastMouseY = finalY
445+
} else {
446+
finalX = x
447+
finalY = y
448+
}
449+
} else {
450+
// Element disappeared or error occurred, use original position
451+
finalX = x
452+
finalY = y
453+
}
454+
455+
// 3. Press and release mouse button with guaranteed cleanup
456+
// Use try-finally to ensure button state is always cleaned up even on error
457+
try {
458+
tab.input.dispatchMouseEvent(
459+
type = "mousePressed",
460+
x = finalX,
461+
y = finalY,
462+
button = button,
463+
buttons = button.buttonsMask,
464+
clickCount = clickCount,
465+
modifiers = modifiers
466+
)
467+
468+
// Randomized delay between press and release (P1 - Anti-detection)
469+
tab.sleep(Random.nextLong(40, 120))
470+
471+
// 4. Release mouse button
472+
tab.input.dispatchMouseEvent(
473+
type = "mouseReleased",
474+
x = finalX,
475+
y = finalY,
476+
button = button,
477+
buttons = button.buttonsMask,
478+
clickCount = clickCount,
479+
modifiers = modifiers
480+
)
481+
} catch (e: Exception) {
482+
// Ensure button is released even on error to prevent stuck button state
483+
runCatching {
484+
tab.input.dispatchMouseEvent(
485+
type = "mouseReleased",
486+
x = finalX,
487+
y = finalY,
488+
button = button
489+
)
490+
}
491+
throw e
492+
}
350493
}
351494

352495
override suspend fun focus() {
@@ -392,7 +535,6 @@ open class DefaultElement(
392535
}
393536

394537
override suspend fun clearInputByDeleting() {
395-
// Focus the element first
396538
focus()
397539

398540
// Set selection range to the beginning and get initial value length atomically
@@ -402,7 +544,7 @@ open class DefaultElement(
402544
el.setSelectionRange(0, 0);
403545
return el.value.length;
404546
}
405-
""".trimIndent()
547+
""".trimIndent()
406548
) ?: 0
407549

408550
// Delete each character using CDP Input.dispatchKeyEvent (P3 - Anti-detection)
@@ -432,26 +574,15 @@ open class DefaultElement(
432574
"""
433575
(el) => {
434576
el.value = el.value.slice(1);
577+
el.dispatchEvent(new Event('input', { bubbles: true }));
435578
return el.value.length;
436579
}
437-
""".trimIndent()
580+
""".trimIndent()
438581
) ?: 0
439582

440583
// Random delay between deletions (50-100ms) for natural variation
441-
if (remaining > 0) {
442-
tab.sleep(kotlin.random.Random.nextLong(50, 100))
443-
}
584+
if (remaining > 0) tab.sleep(Random.nextLong(50, 100))
444585
}
445-
446-
// Dispatch input event to notify the page of the change
447-
apply<String?>(
448-
"""
449-
(el) => {
450-
el.dispatchEvent(new Event('input', { bubbles: true }));
451-
return null;
452-
}
453-
""".trimIndent()
454-
)
455586
}
456587

457588
override suspend fun rawApply(

0 commit comments

Comments
 (0)