diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt b/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt index 6a30d38c3..41d780c6e 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/dom/DefaultElement.kt @@ -531,7 +531,20 @@ open class DefaultElement( } override suspend fun clearInput() { - apply("function (element) { element.value = \"\" }") + // Use the native prototype setter instead of a direct el.value assignment. + // Direct assignment (el.value = "") goes through React's instance-level tracker + // setter, updating trackerValue and the DOM simultaneously. When an input event + // then fires, React sees el.value === trackerValue and concludes nothing changed, + // so onChange is never called. The native prototype setter bypasses the tracker, + // so the subsequent input event correctly triggers React's onChange. + apply( + """ + (el) => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set.call(el, ''); + el.dispatchEvent(new InputEvent('input', { bubbles: true, cancelable: true })); + } + """.trimIndent() + ) } override suspend fun clearInputByDeleting() { @@ -548,10 +561,21 @@ open class DefaultElement( ) ?: 0 // Delete each character using CDP Input.dispatchKeyEvent (P3 - Anti-detection) - // This generates isTrusted: true events unlike JavaScript KeyboardEvent dispatch + // This generates isTrusted: true events unlike JavaScript KeyboardEvent dispatch. + // + // CDP keyDown with key="Delete" at cursor position 0 uses the browser's native + // text-editing pipeline, which bypasses React's instance-level tracker setter. + // This means React sees a mismatch between el.value and trackerValue and correctly + // fires onChange — unlike a direct el.value assignment which updates both + // simultaneously, causing React to silently ignore the change. + // + // Cursor stays at position 0 across iterations: forward-delete (VK_DELETE=46) + // removes the character at the cursor without moving it, so each keyDown always + // operates at the start of the remaining text. var remaining = initialLength while (remaining > 0) { - // Dispatch keydown event + // Dispatch keydown event — this natively deletes one character and fires + // a real input event that React's change detection processes correctly tab.input.dispatchKeyEvent( type = "keyDown", key = "Delete", @@ -569,16 +593,9 @@ open class DefaultElement( nativeVirtualKeyCode = 46 ) - // Actually remove the character from the input value and get remaining length - remaining = apply( - """ - (el) => { - el.value = el.value.slice(1); - el.dispatchEvent(new Event('input', { bubbles: true })); - return el.value.length; - } - """.trimIndent() - ) ?: 0 + // Read remaining length — the native keyDown already handled deletion + // and React notification; no JS value mutation needed here + remaining = apply("(el) => el.value.length") ?: 0 // Random delay between deletions (50-100ms) for natural variation if (remaining > 0) tab.sleep(Random.nextLong(50, 100)) diff --git a/core/src/jvmTest/kotlin/dev/kdriver/core/dom/ReactControlledInputTest.kt b/core/src/jvmTest/kotlin/dev/kdriver/core/dom/ReactControlledInputTest.kt new file mode 100644 index 000000000..442d0a3ae --- /dev/null +++ b/core/src/jvmTest/kotlin/dev/kdriver/core/dom/ReactControlledInputTest.kt @@ -0,0 +1,116 @@ +package dev.kdriver.core.dom + +import dev.kdriver.core.browser.createBrowser +import dev.kdriver.core.sampleFile +import dev.kdriver.core.tab.ReadyState +import dev.kdriver.core.tab.evaluate +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Tests that reproduce the React controlled input silent failure bug. + * + * React installs an instance-level value property setter on controlled inputs + * (its _valueTracker mechanism). Direct `el.value = x` assignments go through + * this setter, updating the tracker's "last known value". When an 'input' event + * then fires, React checks `el.value === tracker.getValue()` — they match — + * so React concludes nothing changed and does NOT call onChange. + * + * kdriver's clearInput() and clearInputByDeleting() both use direct `.value` + * assignments, making them silently ineffective against React controlled inputs. + * The real-world consequence is a "mixed value" when filling a pre-filled input + * (e.g. old value "10", new value "25" → result "1025"). + */ +class ReactControlledInputTest { + + /** + * clearInput() uses `element.value = ""` which goes through React's tracker setter, + * updating both the DOM and trackerValue to "". No 'input' event is dispatched, + * so React's onChange check never runs. React state stays at "10". + */ + @Test + fun testClearInputDoesNotNotifyReact() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + val tab = browser.get(sampleFile("react-controlled-input-test.html")) + tab.waitForReadyState(ReadyState.COMPLETE) + + val input = tab.select("#controlled-input") + + assertEquals("10", tab.evaluate("document.getElementById('state-value').textContent")) + assertEquals("0", tab.evaluate("document.getElementById('change-count').textContent")) + + input.clearInput() + delay(100) + + // Expected: React state is "" (the clear was communicated to React) + // Actual: React state is still "10" (React was never notified) + assertEquals("", tab.evaluate("document.getElementById('state-value').textContent")) + + browser.stop() + } + + /** + * clearInputByDeleting() uses `el.value = el.value.slice(1)` on each iteration. + * Each direct .value assignment goes through React's tracker setter, updating both + * the DOM and trackerValue simultaneously. When the 'input' event fires afterwards, + * React checks `el.value === trackerValue` — they match — so onChange is never called. + * React state stays at "10". + */ + @Test + fun testClearInputByDeletingDoesNotNotifyReact() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + val tab = browser.get(sampleFile("react-controlled-input-test.html")) + tab.waitForReadyState(ReadyState.COMPLETE) + + val input = tab.select("#controlled-input") + + assertEquals("10", tab.evaluate("document.getElementById('state-value').textContent")) + assertEquals("0", tab.evaluate("document.getElementById('change-count').textContent")) + + input.clearInputByDeleting() + delay(100) + + // Expected: React state is "" (every deletion was communicated to React) + // Actual: React state is still "10" (silent failure — tracker matched on each event) + assertEquals("", tab.evaluate("document.getElementById('state-value').textContent")) + + browser.stop() + } + + /** + * The real-world consequence: after clearInputByDeleting silently fails to notify React, + * React's async scheduler re-renders and restores the DOM to its controlled value ("10"). + * insertText("25") then inserts into "10" instead of an empty field, producing a mixed + * value like "1025" instead of the intended "25". + */ + @Test + fun testFillReactControlledInputProducesMixedValue() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + val tab = browser.get(sampleFile("react-controlled-input-test.html")) + tab.waitForReadyState(ReadyState.COMPLETE) + + val input = tab.select("#controlled-input") + + // Step 1: Try to clear — silently fails at React level, DOM appears empty + input.clearInputByDeleting() + + // Step 2: Simulate React's async re-render committing the old controlled state. + // In a real app this happens automatically (React's scheduler) between operations. + // React uses the native prototype setter to revert the DOM to "10". + tab.evaluate("window.simulateReactRerender();'done'") + delay(50) + + // Step 3: Insert the new value — but DOM now holds "10", not "" + input.insertText("25") + delay(100) + + // Expected: "25" (clear worked, so inserting into empty field gives "25") + // Actual: "1025" (inserted at end of "10" that React restored) + assertEquals("25", input.getInputValue()) + + browser.stop() + } + +} diff --git a/core/src/jvmTest/resources/react-controlled-input-test.html b/core/src/jvmTest/resources/react-controlled-input-test.html new file mode 100644 index 000000000..f701e4d23 --- /dev/null +++ b/core/src/jvmTest/resources/react-controlled-input-test.html @@ -0,0 +1,86 @@ + + + + React Controlled Input Test + + + +

React Controlled Input Simulation

+

Simulates a React-controlled input to reproduce the _valueTracker silent failure bug.

+
+ + +
+
+

React state: 10

+

onChange calls: 0

+
+ +