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
This commit is contained in:
pj authored and GitHub committed 2026-04-20 13:31:50 +07:00
1 parent 8381a98aaf
commit c1de4bf57a
8 files changed
+284 -26

No files matched your search

@@ -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"))
}
}
}
@@ -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<String?>(null)
val current: StateFlow<String?> = _current.asStateFlow()
@@ -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",
@@ -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)
+8 -5
View File
@@ -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(
+193
View File
@@ -22,6 +22,45 @@ const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
</node>
</hierarchy>`
const homeHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="logout_button" clickable="true" bounds="[980,80][1060,160]" />
<node index="1" class="android.view.View" content-desc="account_card:acc-1" clickable="true" bounds="[64,320][1016,440]" />
<node index="2" class="android.view.View" content-desc="account_card:acc-2" clickable="true" bounds="[64,460][1016,580]" />
<node index="3" class="android.view.View" content-desc="add_account_button" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const addAccountHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="account_name_field" clickable="true" bounds="[64,320][1016,440]" />
<node index="2" class="android.view.View" content-desc="add_account_submit" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const ledgerHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="add_txn_button" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
const addTxnHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<node index="0" class="android.widget.FrameLayout" package="dev.uatu.sample" bounds="[0,0][1080,2400]">
<node index="0" class="android.view.View" content-desc="Back" clickable="true" bounds="[32,80][112,160]" />
<node index="1" class="android.view.View" content-desc="txn_credit" clickable="true" bounds="[64,280][540,360]" />
<node index="2" class="android.view.View" content-desc="txn_debit" clickable="true" bounds="[540,280][1016,360]" />
<node index="3" class="android.view.View" content-desc="txn_amount" clickable="true" bounds="[64,440][1016,560]" />
<node index="4" class="android.view.View" content-desc="txn_note" clickable="true" bounds="[64,600][1016,720]" />
<node index="5" class="android.view.View" content-desc="txn_submit" clickable="true" bounds="[64,2200][1016,2320]" />
</node>
</hierarchy>`
// 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
}
@@ -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<String> {
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 <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.
@@ -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? {
@@ -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 = """
<hierarchy>