From c1de4bf57aa74108269e807624a1135fc840d8ac Mon Sep 17 00:00:00 2001 From: pjay Date: Mon, 20 Apr 2026 13:31:50 +0700 Subject: [PATCH] fix(sample-app): address PR #18 review findings (#19) - WebLedgerStore.accountExistsByName case-insensitive (matches SQL COLLATE NOCASE) - drop SampleApplication.maybeInjectDebugError; sample spec now passes clean - drop noLogcatErrors from sample spec (default matches system-wide E logs) - openRandomAccount picks uniformly from findAll instead of first match - clear txnError on any add-transaction interaction, not only valid amount input - escapeForAdbInputText quotes shell metacharacters (quotes, backslash, etc.) - extract buildClearKeyevents helper with empty/normal/cap tests - broaden spec_integration_test.go to cover home, add-account, ledger, add-transaction action generators - document FocusTracker single-focus invariant --- .../dev/uatu/sample/SampleApplication.kt | 12 -- .../kotlin/dev/uatu/sample/FocusTracker.kt | 3 + .../dev/uatu/sample/ui/AddTransaction.kt | 12 +- .../kotlin/dev/uatu/sample/WebLedgerStore.kt | 2 +- examples/sample-app/spec.ts | 13 +- internal/verifier/spec_integration_test.go | 193 ++++++++++++++++++ .../kotlin/dev/uatu/sidecar/DriverBackend.kt | 33 ++- .../dev/uatu/sidecar/InputTextClearTest.kt | 42 ++++ 8 files changed, 284 insertions(+), 26 deletions(-) diff --git a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/SampleApplication.kt b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/SampleApplication.kt index 71a6f2b..3c95a65 100644 --- a/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/SampleApplication.kt +++ b/examples/sample-app/composeApp/src/androidMain/kotlin/dev/uatu/sample/SampleApplication.kt @@ -1,7 +1,6 @@ package dev.uatu.sample import android.app.Application -import android.content.pm.ApplicationInfo import dev.uatu.sdk.Uatu class SampleApplication : Application() { @@ -80,16 +79,5 @@ class SampleApplication : Application() { Uatu.extract("login_error") { UiState.loginError.value } Uatu.extract("add_account_error") { UiState.addAccountError.value } Uatu.extract("txn_error") { UiState.txnError.value } - maybeInjectDebugError() - } - - // Fires a synthetic Uatu.reportError in debug builds so the sample-app - // e2e run can verify noUncaughtExceptions surfaces SDK-captured errors - // in the trace. Production builds skip this. - private fun maybeInjectDebugError() { - val isDebuggable = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 - if (isDebuggable) { - Uatu.reportError(RuntimeException("synthetic")) - } } } diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/FocusTracker.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/FocusTracker.kt index e3dd678..fe95708 100644 --- a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/FocusTracker.kt +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/FocusTracker.kt @@ -4,6 +4,9 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +// Assumes single-focus: only one input can be focused at a time, so +// enter() overwriting is safe. leave() is id-gated so a stale dispose +// from a previously-focused field cannot clobber the active focus. object FocusTracker { private val _current = MutableStateFlow(null) val current: StateFlow = _current.asStateFlow() diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddTransaction.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddTransaction.kt index 3527d9e..ec89ae3 100644 --- a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddTransaction.kt +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddTransaction.kt @@ -103,7 +103,10 @@ fun AddTransactionPage(accountId: String) { Segmented( selected = if (type == TxnType.credit) 0 else 1, labels = listOf("Credit", "Debit"), - onSelect = { type = if (it == 0) TxnType.credit else TxnType.debit }, + onSelect = { + UiState.txnError.value = "" + type = if (it == 0) TxnType.credit else TxnType.debit + }, descriptions = listOf("txn_credit", "txn_debit"), ) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -111,9 +114,9 @@ fun AddTransactionPage(accountId: String) { TextInput( value = amount, onChange = { + UiState.txnError.value = "" if (AMOUNT_REGEX.matches(it) || it.isEmpty()) { amount = it - UiState.txnError.value = "" } }, placeholder = "0.00", @@ -129,7 +132,10 @@ fun AddTransactionPage(accountId: String) { FieldLabel("Note (optional)") TextInput( value = note, - onChange = { note = it.take(80) }, + onChange = { + UiState.txnError.value = "" + note = it.take(80) + }, placeholder = "What's this for?", label = "Note", description = "txn_note", diff --git a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/WebLedgerStore.kt b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/WebLedgerStore.kt index 4ec49c3..b42c746 100644 --- a/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/WebLedgerStore.kt +++ b/examples/sample-app/composeApp/src/wasmJsMain/kotlin/dev/uatu/sample/WebLedgerStore.kt @@ -21,7 +21,7 @@ class WebLedgerStore : LedgerStore { } override fun accountExistsByName(name: String): Boolean = - _accounts.value.any { it.name == name } + _accounts.value.any { it.name.equals(name, ignoreCase = true) } override fun insertAccount(id: String, name: String, createdAt: Long) { _accounts.value = _accounts.value + Account(id, name, createdAt) diff --git a/examples/sample-app/spec.ts b/examples/sample-app/spec.ts index 29dbd93..ad63eab 100644 --- a/examples/sample-app/spec.ts +++ b/examples/sample-app/spec.ts @@ -14,7 +14,7 @@ import { waitOnce, weighted, } from "@uatu/spec"; -import { noLogcatErrors, noUncaughtExceptions } from "@uatu/spec/defaults/properties"; +import { noUncaughtExceptions } from "@uatu/spec/defaults/properties"; interface AccountSnapshot { id: string; @@ -90,7 +90,9 @@ const txnCredit = extract((state) => state.ax.find("desc:txn_credit")); const txnDebit = extract((state) => state.ax.find("desc:txn_debit")); const txnSubmit = extract((state) => state.ax.find("desc:txn_submit")); const backButton = extract((state) => state.ax.find("desc:Back")); -const anyAccountCard = extract((state) => state.ax.find("descPrefix:account_card:")); +const allAccountCards = extract((state) => + state.ax.findAll("descPrefix:account_card:"), +); const accountCountNonNegative = always(() => accountCount.current >= 0); @@ -334,8 +336,10 @@ const openAddAccount = actions(() => { const openRandomAccount = actions(() => { if (route.current !== "home") return []; - const card = anyAccountCard.current; - return card ? [Tap({ on: card })] : []; + const cards = allAccountCards.current; + if (cards.length === 0) return []; + const card = cards[Math.floor(Math.random() * cards.length)]; + return [Tap({ on: card })]; }); const logoutAction = actions(() => { @@ -411,7 +415,6 @@ export const properties = { ...stateMachine, ...liveness, noUncaughtExceptions, - noLogcatErrors, }; export const actionsRoot = weighted( diff --git a/internal/verifier/spec_integration_test.go b/internal/verifier/spec_integration_test.go index fa1a5e2..eac6da3 100644 --- a/internal/verifier/spec_integration_test.go +++ b/internal/verifier/spec_integration_test.go @@ -22,6 +22,45 @@ const sampleAppHierarchyXML = ` ` +const homeHierarchyXML = ` + + + + + + + +` + +const addAccountHierarchyXML = ` + + + + + + +` + +const ledgerHierarchyXML = ` + + + + + +` + +const addTxnHierarchyXML = ` + + + + + + + + + +` + // bundleSampleAppSpec bundles examples/sample-app/spec.ts via the real // @uatu/spec API so the integration test exercises the same path the CLI uses. func bundleSampleAppSpec(t *testing.T) string { @@ -71,6 +110,55 @@ func loginSnapshots() Snapshots { } } +// twoAccountsJSON is shared between home, ledger, and add-transaction +// snapshots so invariants that correlate accounts with ledger rows stay +// consistent across routes. +const twoAccountsJSON = `[` + + `{"id":"acc-1","name":"Checking","balance":0,"txnCount":0},` + + `{"id":"acc-2","name":"Savings","balance":0,"txnCount":0}` + + `]` + +func homeSnapshots() Snapshots { + return Snapshots{ + "route": json.RawMessage(`"home"`), + "logged_in": json.RawMessage(`true`), + "auth_status": json.RawMessage(`"logged-in"`), + "account_count": json.RawMessage(`2`), + "accounts": json.RawMessage(twoAccountsJSON), + "total_balance": json.RawMessage(`0`), + "active_account_id": json.RawMessage(`null`), + "ledger_rows": json.RawMessage(`[]`), + "ledger_balance": json.RawMessage(`0`), + "focused_input": json.RawMessage(`null`), + "txn_form_type": json.RawMessage(`null`), + "txn_form_account_id": json.RawMessage(`null`), + "login_error": json.RawMessage(`""`), + "add_account_error": json.RawMessage(`""`), + "txn_error": json.RawMessage(`""`), + } +} + +func addAccountSnapshots() Snapshots { + s := homeSnapshots() + s["route"] = json.RawMessage(`"add-account"`) + return s +} + +func ledgerSnapshots() Snapshots { + s := homeSnapshots() + s["route"] = json.RawMessage(`"ledger"`) + s["active_account_id"] = json.RawMessage(`"acc-1"`) + return s +} + +func addTxnSnapshots() Snapshots { + s := ledgerSnapshots() + s["route"] = json.RawMessage(`"add-transaction"`) + s["txn_form_type"] = json.RawMessage(`"credit"`) + s["txn_form_account_id"] = json.RawMessage(`"acc-1"`) + return s +} + // TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec // emits Tap actions targeting the login screen elements when they are present // in the hierarchy. @@ -145,3 +233,108 @@ func TestSampleAppSpecPropertiesEvaluate(t *testing.T) { t.Errorf("loginReachable: got %v, want pending", verdicts["loginReachable"]) } } + +// TestSampleAppSpecActionsFireOnEachRoute pushes a hierarchy + snapshot pair +// representative of each sample-app route and verifies the expected action +// generators fire against that state. Guards against silent breakage of any +// one route's generators (a regression only e2e would otherwise catch). +func TestSampleAppSpecActionsFireOnEachRoute(t *testing.T) { + cases := []struct { + name string + xml string + snapshots Snapshots + expectKind ActionKind + expectOns []string + }{ + { + name: "home", + xml: homeHierarchyXML, + snapshots: homeSnapshots(), + expectKind: ActionKindTap, + expectOns: []string{ + "desc:add_account_button", + "desc:logout_button", + "descPrefix:account_card:", + }, + }, + { + name: "add-account", + xml: addAccountHierarchyXML, + snapshots: addAccountSnapshots(), + expectKind: ActionKindTap, + expectOns: []string{"desc:add_account_submit", "desc:Back"}, + }, + { + name: "ledger", + xml: ledgerHierarchyXML, + snapshots: ledgerSnapshots(), + expectKind: ActionKindTap, + expectOns: []string{"desc:add_txn_button", "desc:Back"}, + }, + { + name: "add-transaction", + xml: addTxnHierarchyXML, + snapshots: addTxnSnapshots(), + expectKind: ActionKindTap, + expectOns: []string{ + "desc:txn_submit", + "desc:txn_debit", + "desc:Back", + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v := newVerifier(t) + if err := v.Load(bundleSampleAppSpec(t)); err != nil { + t.Fatal(err) + } + tree, err := hierarchy.Parse(tc.xml) + if err != nil { + t.Fatal(err) + } + if err := v.PushSnapshot(SnapshotInput{Snapshots: tc.snapshots, Tree: tree}); err != nil { + t.Fatal(err) + } + sawOn := map[string]bool{} + sawInputText := map[string]bool{} + for range 800 { + action, err := v.NextAction() + if err != nil { + continue + } + if action.Kind == tc.expectKind { + sawOn[action.On] = true + } + if action.Kind == ActionKindInputText { + sawInputText[action.On] = true + } + } + for _, on := range tc.expectOns { + if !sawOn[on] { + t.Errorf("%s: no %s action on %q; saw %v", tc.name, tc.expectKind, on, keysOf(sawOn)) + } + } + if tc.name == "add-account" && !sawInputText["desc:account_name_field"] { + t.Errorf("add-account: typeAccountName never fired; saw %v", keysOf(sawInputText)) + } + if tc.name == "add-transaction" { + if !sawInputText["desc:txn_amount"] { + t.Errorf("add-transaction: typeAmount never fired; saw %v", keysOf(sawInputText)) + } + if !sawInputText["desc:txn_note"] { + t.Errorf("add-transaction: typeNote never fired; saw %v", keysOf(sawInputText)) + } + } + }) + } +} + +func keysOf(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt index 370303e..7aad06e 100644 --- a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt @@ -93,6 +93,32 @@ class StubDriverBackend(private val platform: String) : DriverBackend { // hierarchy that reports an enormous text length for the focused field. internal const val MAX_CLEAR_DELETES: Int = 1024 + internal fun buildClearKeyevents(textLength: Int): List { + if (textLength <= 0) return emptyList() + val deletes = minOf(textLength, MAX_CLEAR_DELETES) + val args = mutableListOf("shell", "input", "keyevent", "KEYCODE_MOVE_END") + repeat(deletes) { args.add("KEYCODE_DEL") } + return args + } + + // `adb shell input text` runs through a remote sh, so shell metacharacters + // in the payload would be interpreted by the device shell. Substitute + // spaces with %s (input's escape) and backslash-escape characters sh + // would otherwise expand. Keep this list conservative; anything not + // listed passes through literally. + internal fun escapeForAdbInputText(text: String): String { + val sb = StringBuilder(text.length) + for (ch in text) { + when (ch) { + ' ' -> sb.append("%s") + '\\', '"', '\'', '&', '|', ';', '<', '>', '(', ')', '*', '?', + '$', '`', '[', ']', '{', '}', '~', '#', -> sb.append('\\').append(ch) + else -> sb.append(ch) + } + } + return sb.toString() + } + // Matches a uiautomator-dump 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. @@ -188,16 +214,13 @@ class StubDriverBackend(private val platform: String) : DriverBackend { // 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"))) + runAdb(listOf("shell", "input", "text", escapeForAdbInputText(text))) } 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) + runAdb(buildClearKeyevents(current.length)) } private fun focusedFieldText(): String? { diff --git a/sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt b/sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt index ebeb1da..5f29f0c 100644 --- a/sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt +++ b/sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt @@ -3,9 +3,51 @@ package dev.uatu.sidecar import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class InputTextClearTest { + @Test fun buildClearKeyeventsEmptyWhenNothingToDelete() { + assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(0)) + assertEquals(emptyList(), StubDriverBackend.buildClearKeyevents(-3)) + } + + @Test fun buildClearKeyeventsPrefixesMoveEndThenOneDelPerChar() { + val args = StubDriverBackend.buildClearKeyevents(3) + assertEquals(listOf("shell", "input", "keyevent", "KEYCODE_MOVE_END", + "KEYCODE_DEL", "KEYCODE_DEL", "KEYCODE_DEL"), args) + } + + @Test fun buildClearKeyeventsCapsAtMaxClearDeletes() { + val huge = StubDriverBackend.MAX_CLEAR_DELETES * 10 + val args = StubDriverBackend.buildClearKeyevents(huge) + val deletes = args.count { it == "KEYCODE_DEL" } + assertEquals(StubDriverBackend.MAX_CLEAR_DELETES, deletes) + assertEquals("KEYCODE_MOVE_END", args[3]) + } + + @Test fun escapeForAdbInputTextSubstitutesSpaces() { + assertEquals("hello%sworld", StubDriverBackend.escapeForAdbInputText("hello world")) + } + + @Test fun escapeForAdbInputTextEscapesShellMetacharacters() { + val escaped = StubDriverBackend.escapeForAdbInputText("a&b|c;d\$e`f") + assertEquals("a\\&b\\|c\\;d\\\$e\\`f", escaped) + } + + @Test fun escapeForAdbInputTextEscapesQuotesAndBackslash() { + assertEquals("\\'", StubDriverBackend.escapeForAdbInputText("'")) + assertEquals("\\\"", StubDriverBackend.escapeForAdbInputText("\"")) + assertEquals("\\\\", StubDriverBackend.escapeForAdbInputText("\\")) + } + + @Test fun escapeForAdbInputTextLeavesSimpleTextAlone() { + assertEquals("12.34", StubDriverBackend.escapeForAdbInputText("12.34")) + assertEquals("Coffee", StubDriverBackend.escapeForAdbInputText("Coffee")) + assertTrue("-5" == StubDriverBackend.escapeForAdbInputText("-5")) + } + + @Test fun parsesTextFromFocusedNode() { val xml = """