feat(sample-app): ledger spec parity (#18)

* feat(sample-app): add FocusTracker

* feat(sample-app): support description on TextInput

* feat(sample-app): support description on AppButton and Segmented

* feat(sample-app): stable ids on login screen

* feat(sample-app): stable ids on add-account screen

* feat(sample-app): stable ids on add-transaction screen

* feat(sample-app): stable ids on home and ledger screens

* feat(sample-app): hoist error + form state into UiState

* feat(sample-app): register auth_status + accounts snapshots

* feat(sample-app): register ledger_rows + ledger_balance snapshots

* feat(sample-app): register error + focused_input snapshots

* refactor(sample-app): scaffold spec.ts extractors + safety

* feat(sample-app): spec accounting invariants

* feat(sample-app): spec state-machine monotonicity

* feat(sample-app): spec liveness properties

* feat(sample-app): spec auth + account action generators

* feat(sample-app): spec transaction action generators

* feat(sample-app): spec weighted workflow

* feat(sample-app): publish login form input snapshots

* feat(sample-app): publish account + txn input snapshots

* feat(sample-app): register form input snapshots

* fix(sample-app): sequence login + idempotent text inputs in spec

* fix(sample-app): clear UiState on page dispose

* fix(sample-app): tighten adversarial login, allow retype on error

* test(verifier): update sample-app spec integration for new selectors

* feat(sidecar): clear focused field before InputText types

* refactor(sample-app): simplify loginHelper to focus-driven sequencing

* refactor(sample-app): drop input-value UiState mirrors

* refactor(sample-app): drop input-value snapshot registrations

* test(verifier): adjust sample-app integration for replace-on-type
This commit is contained in:
pj authored and GitHub committed 2026-04-20 13:28:41 +07:00
1 parent 7493945251
commit 8381a98aaf
13 files changed
+718 -120

No files matched your search

@@ -89,6 +89,31 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
return null
}
// Hard cap on KEYCODE_DEL events per clear. Guards against a pathological
// hierarchy that reports an enormous text length for the focused field.
internal const val MAX_CLEAR_DELETES: Int = 1024
// Matches a uiautomator-dump <node ...> tag where `focused="true"` is
// present. Captures only the tag's attribute string so we can pull
// `text="..."` out of it without building a full XML tree.
private val FOCUSED_NODE = Regex(
"<node\\b([^>]*\\bfocused=\"true\"[^>]*)/?>",
)
private val TEXT_ATTRIBUTE = Regex("\\btext=\"([^\"]*)\"")
internal fun parseFocusedText(xml: String): String? {
val node = FOCUSED_NODE.find(xml) ?: return null
val match = TEXT_ATTRIBUTE.find(node.groupValues[1]) ?: return ""
return decodeXmlAttribute(match.groupValues[1])
}
private fun decodeXmlAttribute(value: String): String = value
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
internal val KEY_MAP: Map<String, String> = mapOf(
"back" to "KEYCODE_BACK",
"home" to "KEYCODE_HOME",
@@ -158,9 +183,34 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
override fun inputText(text: String) {
lastInputText = text
// `adb shell input text` types keystrokes at the caret, so repeated
// calls append. Clear the focused field first so the caller sees a
// pure replace: read the current value's length from the hierarchy,
// then move-end + N backspaces before typing.
clearFocusedField()
runAdb(listOf("shell", "input", "text", text.replace(" ", "%s")))
}
private fun clearFocusedField() {
val current = focusedFieldText() ?: return
if (current.isEmpty()) return
val deletes = minOf(current.length, MAX_CLEAR_DELETES)
val keyevents = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END")
repeat(deletes) { keyevents.add("KEYCODE_DEL") }
runAdb(keyevents)
}
private fun focusedFieldText(): String? {
val xml = try {
hierarchy()
} catch (cause: Exception) {
println("inputText: hierarchy dump failed: $cause")
return null
}
if (xml.isBlank() || xml == "<hierarchy/>") return null
return parseFocusedText(xml)
}
@Volatile var lastSwipe: SwipeRecord? = null
private set
@Volatile var lastKey: String? = null
@@ -0,0 +1,48 @@
package dev.uatu.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class InputTextClearTest {
@Test fun parsesTextFromFocusedNode() {
val xml = """
<hierarchy>
<node text="ignored" focused="false"/>
<node resource-id="app:id/email" text="old@value" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("old@value", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() {
val xml = """<hierarchy><node focused="true"/></hierarchy>"""
assertEquals("", StubDriverBackend.parseFocusedText(xml))
}
@Test fun returnsNullWhenNoFocusedNode() {
val xml = """<hierarchy><node text="x" focused="false"/></hierarchy>"""
assertNull(StubDriverBackend.parseFocusedText(xml))
}
@Test fun decodesXmlEntitiesInAttribute() {
val xml = """<hierarchy><node text="a&amp;b&lt;c&quot;d" focused="true"/></hierarchy>"""
assertEquals("a&b<c\"d", StubDriverBackend.parseFocusedText(xml))
}
@Test fun picksFirstFocusedNodeWhenMultiplePresent() {
val xml = """
<hierarchy>
<node text="first" focused="true"/>
<node text="second" focused="true"/>
</hierarchy>
""".trimIndent()
assertEquals("first", StubDriverBackend.parseFocusedText(xml))
}
}