From 8381a98aaf40aa1255ef68832ab31860325f44d1 Mon Sep 17 00:00:00 2001 From: pjay Date: Mon, 20 Apr 2026 13:28:41 +0700 Subject: [PATCH] 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 --- .../dev/uatu/sample/SampleApplication.kt | 58 +++ .../kotlin/dev/uatu/sample/FocusTracker.kt | 18 + .../kotlin/dev/uatu/sample/UiState.kt | 10 + .../kotlin/dev/uatu/sample/ui/AddAccount.kt | 22 +- .../dev/uatu/sample/ui/AddTransaction.kt | 29 +- .../kotlin/dev/uatu/sample/ui/Home.kt | 8 +- .../kotlin/dev/uatu/sample/ui/Ledger.kt | 9 +- .../kotlin/dev/uatu/sample/ui/Login.kt | 25 +- .../kotlin/dev/uatu/sample/ui/Widgets.kt | 31 +- examples/sample-app/spec.ts | 453 +++++++++++++++--- internal/verifier/spec_integration_test.go | 77 +-- .../kotlin/dev/uatu/sidecar/DriverBackend.kt | 50 ++ .../dev/uatu/sidecar/InputTextClearTest.kt | 48 ++ 13 files changed, 718 insertions(+), 120 deletions(-) create mode 100644 examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/FocusTracker.kt create mode 100644 examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/UiState.kt create mode 100644 sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt 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 426af68..71a6f2b 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 @@ -22,6 +22,64 @@ class SampleApplication : Application() { is Route.AddTransaction -> "add-transaction" } } + Uatu.extract("auth_status") { + if (Repository.session.value != null) "logged-in" else "logged-out" + } + Uatu.extract("accounts") { + val txns = Repository.transactions.value + Repository.accounts.value.map { a -> + val rows = txns.filter { it.accountId == a.id } + mapOf( + "id" to a.id, + "name" to a.name, + "balance" to balanceOf(rows), + "txnCount" to rows.size, + ) + } + } + Uatu.extract("active_account_id") { + when (val r = Navigator.current.value) { + is Route.Ledger -> r.accountId + is Route.AddTransaction -> r.accountId + else -> null + } + } + Uatu.extract("ledger_rows") { + val active = when (val r = Navigator.current.value) { + is Route.Ledger -> r.accountId + is Route.AddTransaction -> r.accountId + else -> null + } + if (active == null) emptyList() + else Repository.transactions.value + .filter { it.accountId == active } + .map { + mapOf( + "id" to it.id, + "accountId" to it.accountId, + "type" to if (it.type == TxnType.credit) "credit" else "debit", + "amount" to it.amount, + "signed" to signedAmount(it), + ) + } + } + Uatu.extract("ledger_balance") { + val active = when (val r = Navigator.current.value) { + is Route.Ledger -> r.accountId + is Route.AddTransaction -> r.accountId + else -> null + } + if (active == null) 0L + else balanceOf(Repository.transactions.value.filter { it.accountId == active }) + } + Uatu.extract("focused_input") { FocusTracker.current.value } + Uatu.extract("txn_form_type") { UiState.txnFormType.value } + Uatu.extract("txn_form_account_id") { + (Navigator.current.value as? Route.AddTransaction)?.accountId + } + Uatu.extract("login_error") { UiState.loginError.value } + Uatu.extract("add_account_error") { UiState.addAccountError.value } + Uatu.extract("txn_error") { UiState.txnError.value } maybeInjectDebugError() } 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 new file mode 100644 index 0000000..e3dd678 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/FocusTracker.kt @@ -0,0 +1,18 @@ +package dev.uatu.sample + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +object FocusTracker { + private val _current = MutableStateFlow(null) + val current: StateFlow = _current.asStateFlow() + + fun enter(id: String) { + _current.value = id + } + + fun leave(id: String) { + if (_current.value == id) _current.value = null + } +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/UiState.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/UiState.kt new file mode 100644 index 0000000..39a77a9 --- /dev/null +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/UiState.kt @@ -0,0 +1,10 @@ +package dev.uatu.sample + +import kotlinx.coroutines.flow.MutableStateFlow + +object UiState { + val loginError = MutableStateFlow("") + val addAccountError = MutableStateFlow("") + val txnError = MutableStateFlow("") + val txnFormType = MutableStateFlow(null) +} diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddAccount.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddAccount.kt index 52b34d3..3b48ed3 100644 --- a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddAccount.kt +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/AddAccount.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -15,28 +17,34 @@ import androidx.compose.ui.unit.dp import dev.uatu.sample.Navigator import dev.uatu.sample.Repository import dev.uatu.sample.Route +import dev.uatu.sample.UiState @Composable fun AddAccountPage() { val t = LocalTokens.current var name by remember { mutableStateOf("") } - var err by remember { mutableStateOf(null) } + val err by UiState.addAccountError.collectAsState() BackHandler { Navigator.back(Route.Home) } + DisposableEffect(Unit) { + onDispose { UiState.addAccountError.value = "" } + } + fun submit() { val trimmed = name.trim() if (trimmed.isEmpty()) { - err = "Account name is required"; return + UiState.addAccountError.value = "Account name is required"; return } if (trimmed.length > 40) { - err = "Name is too long (max 40 characters)"; return + UiState.addAccountError.value = "Name is too long (max 40 characters)"; return } try { Repository.createAccount(trimmed) + UiState.addAccountError.value = "" Navigator.replace(Route.Home) } catch (e: IllegalArgumentException) { - err = e.message ?: "Could not create account" + UiState.addAccountError.value = e.message ?: "Could not create account" } } @@ -50,6 +58,7 @@ fun AddAccountPage() { onClick = ::submit, style = ButtonStyle.Primary, enabled = name.trim().isNotEmpty(), + description = "add_account_submit", ) }, ) { @@ -61,10 +70,11 @@ fun AddAccountPage() { FieldLabel("Account name") TextInput( value = name, - onChange = { name = it; err = null }, + onChange = { name = it; UiState.addAccountError.value = "" }, placeholder = "e.g. Checking", - invalid = err != null, + invalid = err.isNotEmpty(), label = "Account name", + description = "account_name_field", ) } ErrorText(err) 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 4d3b8b8..3527d9e 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.collectAsState import androidx.compose.runtime.mutableStateOf @@ -18,6 +19,7 @@ import dev.uatu.sample.Navigator import dev.uatu.sample.Repository import dev.uatu.sample.Route import dev.uatu.sample.TxnType +import dev.uatu.sample.UiState import dev.uatu.sample.parseCents private val AMOUNT_REGEX = Regex("""^\d*(\.\d{0,2})?$""") @@ -48,21 +50,31 @@ fun AddTransactionPage(accountId: String) { var type by remember { mutableStateOf(TxnType.credit) } var amount by remember { mutableStateOf("") } var note by remember { mutableStateOf("") } - var err by remember { mutableStateOf(null) } + val err by UiState.txnError.collectAsState() + + DisposableEffect(type) { + UiState.txnFormType.value = if (type == TxnType.credit) "credit" else "debit" + onDispose { UiState.txnFormType.value = null } + } + + DisposableEffect(Unit) { + onDispose { UiState.txnError.value = "" } + } fun submit() { val cents = parseCents(amount) if (cents == null) { - err = "Enter a valid amount (e.g. 12.34)"; return + UiState.txnError.value = "Enter a valid amount (e.g. 12.34)"; return } if (cents <= 0) { - err = "Amount must be greater than zero"; return + UiState.txnError.value = "Amount must be greater than zero"; return } try { Repository.createTransaction(accountId, type, cents, note) + UiState.txnError.value = "" Navigator.back(Route.Home) } catch (e: IllegalArgumentException) { - err = e.message ?: "Could not save transaction" + UiState.txnError.value = e.message ?: "Could not save transaction" } } @@ -80,6 +92,7 @@ fun AddTransactionPage(accountId: String) { onClick = ::submit, style = ButtonStyle.Primary, enabled = amount.isNotBlank(), + description = "txn_submit", ) }, ) { @@ -91,6 +104,7 @@ fun AddTransactionPage(accountId: String) { selected = if (type == TxnType.credit) 0 else 1, labels = listOf("Credit", "Debit"), onSelect = { type = if (it == 0) TxnType.credit else TxnType.debit }, + descriptions = listOf("txn_credit", "txn_debit"), ) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Amount") @@ -98,15 +112,17 @@ fun AddTransactionPage(accountId: String) { value = amount, onChange = { if (AMOUNT_REGEX.matches(it) || it.isEmpty()) { - amount = it; err = null + amount = it + UiState.txnError.value = "" } }, placeholder = "0.00", - invalid = err != null, + invalid = err.isNotEmpty(), keyboardType = KeyboardType.Decimal, textAlign = TextAlign.Center, textStyle = Type.amountInput, label = "Amount", + description = "txn_amount", ) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -116,6 +132,7 @@ fun AddTransactionPage(accountId: String) { onChange = { note = it.take(80) }, placeholder = "What's this for?", label = "Note", + description = "txn_note", ) } ErrorText(err) diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Home.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Home.kt index df0fa2e..88107c6 100644 --- a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Home.kt +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Home.kt @@ -44,7 +44,7 @@ fun HomePage(user: String, onLogout: () -> Unit) { title = "Accounts", subtitle = user, right = { - IconButton(onClick = onLogout, description = "Sign out", icon = Icons.Logout) + IconButton(onClick = onLogout, description = "logout_button", icon = Icons.Logout) }, ) }, @@ -65,6 +65,7 @@ fun HomePage(user: String, onLogout: () -> Unit) { text = "+ Add account", onClick = { Navigator.push(Route.AddAccount) }, style = ButtonStyle.Primary, + description = "add_account_button", ) }, ) { @@ -80,6 +81,7 @@ fun HomePage(user: String, onLogout: () -> Unit) { val bal = txns.filter { it.accountId == a.id }.sumOf { signedAmount(it) } val count = txns.count { it.accountId == a.id } AccountCard( + id = a.id, name = a.name, initials = initialsOf(a.name), count = count, @@ -94,6 +96,7 @@ fun HomePage(user: String, onLogout: () -> Unit) { @Composable private fun AccountCard( + id: String, name: String, initials: String, count: Int, @@ -102,14 +105,13 @@ private fun AccountCard( ) { val t = LocalTokens.current val txnLabel = if (count == 1) "1 transaction" else "$count transactions" - val a11y = "$name account, balance ${formatCents(balance)}, $txnLabel" Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(RadiusLg)) .background(t.surface) .border(1.dp, t.border, RoundedCornerShape(RadiusLg)) - .semantics(mergeDescendants = true) { contentDescription = a11y } + .semantics(mergeDescendants = true) { contentDescription = "account_card:$id" } .clickable(role = Role.Button, onClick = onClick) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Ledger.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Ledger.kt index d72cd34..dd6f4f0 100644 --- a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Ledger.kt +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Ledger.kt @@ -74,6 +74,7 @@ fun LedgerPage(accountId: String) { text = "+ Add transaction", onClick = { Navigator.push(Route.AddTransaction(accountId)) }, style = ButtonStyle.Primary, + description = "add_txn_button", ) }, ) { @@ -103,7 +104,7 @@ fun LedgerPage(accountId: String) { .padding(horizontal = 16.dp), ) { txns.forEachIndexed { i, txn -> - TxnRow(txn.type, txn.amount, txn.note, formatDate(txn.createdAt)) + TxnRow(txn.id, txn.type, txn.amount, txn.note, formatDate(txn.createdAt)) if (i != txns.lastIndex) { Box(Modifier.fillMaxWidth().height(1.dp).background(t.border)) } @@ -114,16 +115,14 @@ fun LedgerPage(accountId: String) { } @Composable -private fun TxnRow(type: TxnType, amount: Long, note: String, date: String) { +private fun TxnRow(id: String, type: TxnType, amount: Long, note: String, date: String) { val t = LocalTokens.current val signed = if (type == TxnType.credit) amount else -amount - val label = "${if (type == TxnType.credit) "Credit" else "Debit"} ${formatCents(signed, signed = true)}" + - (if (note.isNotEmpty()) ", $note" else "") + ", $date" Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 14.dp) - .semantics(mergeDescendants = true) { contentDescription = label }, + .semantics(mergeDescendants = true) { contentDescription = "txn_row:$id" }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Login.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Login.kt index 6aa469a..f0b4a67 100644 --- a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Login.kt +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Login.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -18,6 +20,7 @@ import androidx.compose.ui.unit.dp import dev.uatu.sample.DEMO_EMAIL import dev.uatu.sample.DEMO_PASSWORD import dev.uatu.sample.Repository +import dev.uatu.sample.UiState import dev.uatu.sample.checkCredentials @Composable @@ -25,15 +28,20 @@ fun LoginPage(onLoggedIn: (String) -> Unit) { val t = LocalTokens.current var email by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } - var err by remember { mutableStateOf(null) } + val err by UiState.loginError.collectAsState() + + DisposableEffect(Unit) { + onDispose { UiState.loginError.value = "" } + } fun submit() { if (email.isBlank() || password.isEmpty()) { - err = "Enter email and password"; return + UiState.loginError.value = "Enter email and password"; return } if (!checkCredentials(email, password)) { - err = "Invalid email or password"; return + UiState.loginError.value = "Invalid email or password"; return } + UiState.loginError.value = "" val user = email.trim().lowercase() Repository.setSession(user) onLoggedIn(user) @@ -49,23 +57,25 @@ fun LoginPage(onLoggedIn: (String) -> Unit) { FieldLabel("Email") TextInput( value = email, - onChange = { email = it; err = null }, + onChange = { email = it; UiState.loginError.value = "" }, placeholder = DEMO_EMAIL, - invalid = err != null, + invalid = err.isNotEmpty(), keyboardType = KeyboardType.Email, label = "Email", + description = "login_email", ) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { FieldLabel("Password") TextInput( value = password, - onChange = { password = it; err = null }, + onChange = { password = it; UiState.loginError.value = "" }, placeholder = "••••••••", password = true, - invalid = err != null, + invalid = err.isNotEmpty(), keyboardType = KeyboardType.Password, label = "Password", + description = "login_password", ) } ErrorText(err) @@ -73,6 +83,7 @@ fun LoginPage(onLoggedIn: (String) -> Unit) { text = "Sign in", onClick = ::submit, style = ButtonStyle.Primary, + description = "login_submit", ) Spacer(Modifier.height(4.dp)) Card(dashed = true) { diff --git a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Widgets.kt b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Widgets.kt index ab3b28b..33438fe 100644 --- a/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Widgets.kt +++ b/examples/sample-app/composeApp/src/commonMain/kotlin/dev/uatu/sample/ui/Widgets.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.uatu.sample.FocusTracker enum class ButtonStyle { Primary, Secondary, Ghost } @@ -46,6 +47,7 @@ fun AppButton( modifier: Modifier = Modifier, style: ButtonStyle = ButtonStyle.Secondary, enabled: Boolean = true, + description: String? = null, ) { val t = LocalTokens.current val (bg, fg, border) = when (style) { @@ -59,6 +61,10 @@ fun AppButton( .clip(RoundedCornerShape(RadiusMd)) .background(if (enabled) bg else t.surface3) .border(BorderStroke(1.dp, if (enabled) border else t.border), RoundedCornerShape(RadiusMd)) + .then( + if (description != null) Modifier.semantics { contentDescription = description } + else Modifier + ) .clickable(enabled = enabled, role = Role.Button, onClick = onClick) .padding(vertical = 14.dp, horizontal = 16.dp), contentAlignment = Alignment.Center, @@ -84,6 +90,7 @@ fun TextInput( textAlign: TextAlign = TextAlign.Start, textStyle: TextStyle = Type.body, label: String? = null, + description: String? = null, modifier: Modifier = Modifier, ) { val t = LocalTokens.current @@ -116,9 +123,16 @@ fun TextInput( cursorBrush = SolidColor(t.text), modifier = Modifier .fillMaxWidth() - .onFocusChanged { focused = it.isFocused } + .onFocusChanged { + focused = it.isFocused + if (description != null) { + if (it.isFocused) FocusTracker.enter(description) + else FocusTracker.leave(description) + } + } .semantics { - if (label != null) contentDescription = label + val desc = description ?: label + if (desc != null) contentDescription = desc if (invalid) stateDescription = "Invalid" }, ) @@ -155,7 +169,12 @@ fun ErrorText(err: String?) { } @Composable -fun Segmented(selected: Int, labels: List, onSelect: (Int) -> Unit) { +fun Segmented( + selected: Int, + labels: List, + onSelect: (Int) -> Unit, + descriptions: List? = null, +) { val t = LocalTokens.current Row( modifier = Modifier @@ -168,12 +187,16 @@ fun Segmented(selected: Int, labels: List, onSelect: (Int) -> Unit) { ) { labels.forEachIndexed { i, label -> val active = i == selected + val desc = descriptions?.getOrNull(i) Box( modifier = Modifier .weight(1f) .clip(RoundedCornerShape(RadiusSm)) .background(if (active) t.surface3 else t.surface) - .semantics { this.selected = active } + .semantics { + this.selected = active + if (desc != null) contentDescription = desc + } .clickable(role = Role.Tab) { onSelect(i) } .padding(vertical = 10.dp), contentAlignment = Alignment.Center, diff --git a/examples/sample-app/spec.ts b/examples/sample-app/spec.ts index ff4e40c..29dbd93 100644 --- a/examples/sample-app/spec.ts +++ b/examples/sample-app/spec.ts @@ -14,89 +14,424 @@ import { waitOnce, weighted, } from "@uatu/spec"; -import { noUncaughtExceptions } from "@uatu/spec/defaults/properties"; +import { noLogcatErrors, noUncaughtExceptions } from "@uatu/spec/defaults/properties"; + +interface AccountSnapshot { + id: string; + name: string; + balance: number; + txnCount: number; +} + +interface LedgerRow { + id: string; + accountId: string; + type: "credit" | "debit"; + amount: number; + signed: number; +} -// ── Snapshot extractors (fed by SampleApplication.kt) ────────── const loggedIn = extract( (state) => (state.snapshots.logged_in as boolean) ?? false, ); +const authStatus = extract( + (state) => (state.snapshots.auth_status as string) ?? "", +); const route = extract( (state) => (state.snapshots.route as string) ?? "", ); +const accounts = extract( + (state) => (state.snapshots.accounts as AccountSnapshot[]) ?? [], +); +const totalBalance = extract( + (state) => (state.snapshots.total_balance as number) ?? 0, +); const accountCount = extract( (state) => (state.snapshots.account_count as number) ?? 0, ); +const activeAccountId = extract( + (state) => (state.snapshots.active_account_id as string | null) ?? null, +); +const ledgerRows = extract( + (state) => (state.snapshots.ledger_rows as LedgerRow[]) ?? [], +); +const ledgerBalance = extract( + (state) => (state.snapshots.ledger_balance as number) ?? 0, +); +const focusedInput = extract( + (state) => (state.snapshots.focused_input as string | null) ?? null, +); +const txnFormType = extract( + (state) => (state.snapshots.txn_form_type as string | null) ?? null, +); +const txnFormAccountId = extract( + (state) => (state.snapshots.txn_form_account_id as string | null) ?? null, +); +const loginError = extract( + (state) => (state.snapshots.login_error as string) ?? "", +); +const addAccountError = extract( + (state) => (state.snapshots.add_account_error as string) ?? "", +); +const txnError = extract( + (state) => (state.snapshots.txn_error as string) ?? "", +); +const loginEmailField = extract((state) => state.ax.find("desc:login_email")); +const loginPasswordField = extract((state) => state.ax.find("desc:login_password")); +const loginSubmitButton = extract((state) => state.ax.find("desc:login_submit")); +const addAccountButton = extract((state) => state.ax.find("desc:add_account_button")); +const logoutButton = extract((state) => state.ax.find("desc:logout_button")); +const accountNameField = extract((state) => state.ax.find("desc:account_name_field")); +const addAccountSubmit = extract((state) => state.ax.find("desc:add_account_submit")); +const addTxnButton = extract((state) => state.ax.find("desc:add_txn_button")); +const txnAmountField = extract((state) => state.ax.find("desc:txn_amount")); +const txnNoteField = extract((state) => state.ax.find("desc:txn_note")); +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:")); -// ── UI elements ──────────────────────────────────────────────── -const phoneField = extract((state) => state.ax.find("desc:phone_field")); -const continueButton = extract((state) => state.ax.find("text:Continue")); -const addAccountButton = extract((state) => state.ax.find("text:Add account")); -const nameField = extract((state) => state.ax.find("desc:account_name")); -const createButton = extract((state) => state.ax.find("text:Create")); - -// ── Properties ───────────────────────────────────────────────── -// accountCountNonNegative: the trivial safety property. const accountCountNonNegative = always(() => accountCount.current >= 0); -// addAccountAdvances: once we land on add-account, the next step must be on -// a different screen. Exercises now(x).implies(next(y)). -const addAccountAdvances = always( - now(() => route.current === "add-account").implies( - next(() => route.current !== "add-account"), +const onHome = () => route.current === "home"; +const onLedger = () => + route.current === "ledger" || route.current === "add-transaction"; +const isInteger = (n: number) => Number.isFinite(n) && Math.floor(n) === n; + +const totalBalanceMatchesAccounts = always( + now(onHome).implies( + now(() => { + const sum = accounts.current.reduce((acc, a) => acc + a.balance, 0); + return sum === totalBalance.current; + }), ), ); -// eventuallyLoggedIn: within 30 seconds of the run starting, we expect to -// reach home. Exercises eventually(p).within(n, unit). -const eventuallyLoggedIn = eventually(() => loggedIn.current).within( - 30, - "seconds", +const ledgerBalanceMatchesRows = always( + now(onLedger).implies( + now(() => { + const sum = ledgerRows.current.reduce((acc, r) => acc + r.signed, 0); + return sum === ledgerBalance.current; + }), + ), ); +const ledgerRowsWellFormed = always(() => { + for (const row of ledgerRows.current) { + if (row.type !== "credit" && row.type !== "debit") return false; + if (!(row.amount > 0)) return false; + const expected = row.type === "credit" ? row.amount : -row.amount; + if (row.signed !== expected) return false; + } + return true; +}); + +const balancesAreIntegerCents = always(() => { + if (!isInteger(totalBalance.current)) return false; + if (!isInteger(ledgerBalance.current)) return false; + for (const a of accounts.current) if (!isInteger(a.balance)) return false; + for (const r of ledgerRows.current) { + if (!isInteger(r.amount) || !isInteger(r.signed)) return false; + } + return true; +}); + +const accountCountMatchesList = always( + () => accountCount.current === accounts.current.length, +); + +const ledgerCountMatchesRows = always( + now(onLedger).implies( + now(() => { + const active = activeAccountId.current; + if (active === null) return true; + const fromAccounts = accounts.current.find((a) => a.id === active); + if (!fromAccounts) return true; + return fromAccounts.txnCount === ledgerRows.current.length; + }), + ), +); + +const zeroTxnsMeansZeroBalance = always(() => { + for (const a of accounts.current) { + if (a.txnCount === 0 && a.balance !== 0) return false; + } + return true; +}); + +const noOrphanTransactions = always(() => { + const active = activeAccountId.current; + if (active === null) return ledgerRows.current.length === 0; + return ledgerRows.current.every((r) => r.accountId === active); +}); + +const uniqueAccountNames = always(() => { + const seen = new Set(); + for (const a of accounts.current) { + const key = a.name.trim().toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + } + return true; +}); + +const accountingInvariants = { + totalBalanceMatchesAccounts, + ledgerBalanceMatchesRows, + ledgerRowsWellFormed, + balancesAreIntegerCents, + accountCountMatchesList, + ledgerCountMatchesRows, + zeroTxnsMeansZeroBalance, + noOrphanTransactions, + uniqueAccountNames, +}; + +const accountsOnlyGrow = always( + now(() => true).implies( + next(() => accounts.current.length >= (accounts.previous?.length ?? 0)), + ), +); + +const ledgerOnlyGrowsPerAccount = always( + now(() => activeAccountId.current !== null).implies( + next(() => { + if (activeAccountId.current !== activeAccountId.previous) return true; + return ledgerRows.current.length >= (ledgerRows.previous?.length ?? 0); + }), + ), +); + +const authStatusIsKnown = always( + () => authStatus.current === "logged-in" || authStatus.current === "logged-out", +); + +const routeIsKnown = always(() => { + const r = route.current; + return ( + r === "login" || + r === "home" || + r === "add-account" || + r === "ledger" || + r === "add-transaction" + ); +}); + +const loggedInLeavesLogin = always( + now(() => loggedIn.current).implies( + eventually(() => route.current !== "login").within(3, "seconds"), + ), +); + +const loggedOutReachesLogin = always( + now(() => !loggedIn.current).implies( + eventually(() => route.current === "login").within(3, "seconds"), + ), +); + +const stateMachine = { + accountsOnlyGrow, + ledgerOnlyGrowsPerAccount, + authStatusIsKnown, + routeIsKnown, + loggedInLeavesLogin, + loggedOutReachesLogin, +}; + +const loginReachable = eventually(() => loggedIn.current).within(90, "seconds"); +const accountCreationReachable = eventually( + () => accounts.current.length > 0, +).within(180, "seconds"); +const someTransactionExists = eventually(() => + accounts.current.some((a) => a.txnCount > 0), +).within(300, "seconds"); + +const loginErrorClears = always( + now(() => loginError.current !== "").implies( + eventually(() => loginError.current === "").within(30, "seconds"), + ), +); +const addAccountErrorClears = always( + now(() => addAccountError.current !== "").implies( + eventually(() => addAccountError.current === "").within(30, "seconds"), + ), +); +const txnErrorClears = always( + now(() => txnError.current !== "").implies( + eventually(() => txnError.current === "").within(30, "seconds"), + ), +); + +const liveness = { + loginReachable, + accountCreationReachable, + someTransactionExists, + loginErrorClears, + addAccountErrorClears, + txnErrorClears, +}; + +const DEMO_EMAIL = "demo@ledger.app"; +const DEMO_PASSWORD = "ledger123"; + +const loginHelper = actions(() => { + if (loggedIn.current) return []; + const focus = focusedInput.current; + const email = loginEmailField.current; + const password = loginPasswordField.current; + const submit = loginSubmitButton.current; + + if (focus === "login_password") { + return submit ? [Tap({ on: submit })] : []; + } + if (focus === "login_email") { + return password ? [InputText({ into: password, text: DEMO_PASSWORD })] : []; + } + return email ? [InputText({ into: email, text: DEMO_EMAIL })] : []; +}); + +const adversarialLogin = actions(() => { + if (loggedIn.current) return []; + if (focusedInput.current !== null) return []; + const submit = loginSubmitButton.current; + if (!submit) return []; + return [Tap({ on: submit })]; +}); + +const accountNameSampler = from([ + "Checking", + "Savings", + "Travel", + "Rent", + "Emergency Fund", + "Investments", + "Groceries", + " ", + "Checking", + "A".repeat(41), + "Petty Cash", +]); + +const typeAccountName = actions(() => { + if (route.current !== "add-account") return []; + const field = accountNameField.current; + if (!field) return []; + return [InputText({ into: field, text: accountNameSampler.generate() })]; +}); + +const submitAddAccount = actions(() => { + if (route.current !== "add-account") return []; + const submit = addAccountSubmit.current; + return submit ? [Tap({ on: submit })] : []; +}); + +const openAddAccount = actions(() => { + if (route.current !== "home") return []; + const button = addAccountButton.current; + return button ? [Tap({ on: button })] : []; +}); + +const openRandomAccount = actions(() => { + if (route.current !== "home") return []; + const card = anyAccountCard.current; + return card ? [Tap({ on: card })] : []; +}); + +const logoutAction = actions(() => { + if (route.current !== "home") return []; + const button = logoutButton.current; + return button ? [Tap({ on: button })] : []; +}); + +const goBack = actions(() => { + const button = backButton.current; + return button ? [Tap({ on: button })] : []; +}); + +const amountSampler = from([ + "12.34", + "100", + "0.01", + "999.99", + "5.5", + "42", + "0", + "", + "1e4", + "0.001", + "-5", +]); + +const typeAmount = actions(() => { + if (route.current !== "add-transaction") return []; + const field = txnAmountField.current; + if (!field) return []; + return [InputText({ into: field, text: amountSampler.generate() })]; +}); + +const noteSampler = from([ + "Coffee", + "Paycheck", + "Gas", + "Refund", + "", + "Groceries for the week", +]); + +const typeNote = actions(() => { + if (route.current !== "add-transaction") return []; + const field = txnNoteField.current; + if (!field) return []; + return [InputText({ into: field, text: noteSampler.generate() })]; +}); + +const toggleTxnType = actions(() => { + if (route.current !== "add-transaction") return []; + const current = txnFormType.current; + const target = current === "credit" ? txnDebit.current : txnCredit.current; + return target ? [Tap({ on: target })] : []; +}); + +const submitTxn = actions(() => { + if (route.current !== "add-transaction") return []; + const submit = txnSubmit.current; + return submit ? [Tap({ on: submit })] : []; +}); + +const openAddTxn = actions(() => { + if (route.current !== "ledger") return []; + const button = addTxnButton.current; + return button ? [Tap({ on: button })] : []; +}); + export const properties = { accountCountNonNegative, - addAccountAdvances, - eventuallyLoggedIn, + ...accountingInvariants, + ...stateMachine, + ...liveness, noUncaughtExceptions, + noLogcatErrors, }; -// ── Actions ──────────────────────────────────────────────────── -// Sampling: random phone numbers for the login screen. -const phoneSampler = from(["+919876543210", "+15555550100", "+442071234567"]); - -const typePhone = actions(() => { - const field = phoneField.current; - if (!field) return []; - return [InputText({ into: field, text: phoneSampler.generate() })]; -}); - -const tapContinue = actions(() => - continueButton.current ? [Tap({ on: continueButton.current })] : [], -); -const tapAddAccount = actions(() => - addAccountButton.current ? [Tap({ on: addAccountButton.current })] : [], -); - -const nameSampler = from(["Alice", "Bob", "Charlie", "Dana"]); -const fillName = actions(() => { - const field = nameField.current; - if (!field) return []; - return [InputText({ into: field, text: nameSampler.generate() })]; -}); -const tapCreate = actions(() => - createButton.current ? [Tap({ on: createButton.current })] : [], -); - export const actionsRoot = weighted( - [30, typePhone], - [30, tapContinue], - [20, tapAddAccount], - [20, fillName], - [20, tapCreate], - [10, taps], - [5, swipes], - [5, waitOnce], - [5, pressKey], + [30, loginHelper], + [2, adversarialLogin], + [18, typeAccountName], + [14, submitAddAccount], + [18, typeAmount], + [8, typeNote], + [6, toggleTxnType], + [16, submitTxn], + [14, openAddAccount], + [14, openRandomAccount], + [12, openAddTxn], + [6, goBack], + [1, logoutAction], + [4, taps], + [2, swipes], + [2, waitOnce], + [2, pressKey], ); (globalThis as { actions?: unknown; properties?: unknown }).actions = actionsRoot; diff --git a/internal/verifier/spec_integration_test.go b/internal/verifier/spec_integration_test.go index f7045e5..fa1a5e2 100644 --- a/internal/verifier/spec_integration_test.go +++ b/internal/verifier/spec_integration_test.go @@ -15,8 +15,9 @@ const sampleAppHierarchyXML = ` - - + + + ` @@ -40,7 +41,7 @@ func bundleSampleAppSpec(t *testing.T) string { bundle, err := bundler.Bundle(bundler.Options{ EntryFile: specPath, Aliases: map[string]string{ - "@uatu/spec": apiPath, + "@uatu/spec": apiPath, "@uatu/spec/defaults/properties": defaultsPath, }, }) @@ -50,9 +51,29 @@ func bundleSampleAppSpec(t *testing.T) string { return string(bundle.JavaScript) } +func loginSnapshots() Snapshots { + return Snapshots{ + "route": json.RawMessage(`"login"`), + "logged_in": json.RawMessage(`false`), + "auth_status": json.RawMessage(`"logged-out"`), + "account_count": json.RawMessage(`0`), + "accounts": json.RawMessage(`[]`), + "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(`""`), + } +} + // TestSampleAppSpecFiresLoginActions verifies the bundled sample-app spec -// emits Tap/InputText actions targeting the login screen elements when they -// are present in the hierarchy. +// emits Tap actions targeting the login screen elements when they are present +// in the hierarchy. func TestSampleAppSpecFiresLoginActions(t *testing.T) { v := newVerifier(t) if err := v.Load(bundleSampleAppSpec(t)); err != nil { @@ -63,34 +84,29 @@ func TestSampleAppSpecFiresLoginActions(t *testing.T) { if err != nil { t.Fatal(err) } - snapshots := Snapshots{ - "route": json.RawMessage(`"login"`), - "logged_in": json.RawMessage(`false`), - "account_count": json.RawMessage(`0`), - } - if err := v.PushSnapshot(SnapshotInput{Snapshots: snapshots, Tree: tree}); err != nil { + if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil { t.Fatal(err) } - tapContinueHits := 0 - typePhoneHits := 0 + typeEmailHits := 0 + tapSubmitHits := 0 for range 400 { action, err := v.NextAction() if err != nil { continue } switch { - case action.Kind == ActionKindTap && action.On == "text:Continue": - tapContinueHits++ - case action.Kind == ActionKindInputText && action.On == "desc:phone_field": - typePhoneHits++ + case action.Kind == ActionKindInputText && action.On == "desc:login_email": + typeEmailHits++ + case action.Kind == ActionKindTap && action.On == "desc:login_submit": + tapSubmitHits++ } } - if tapContinueHits == 0 { - t.Fatal("tapContinue never fired on sample-app hierarchy") + if typeEmailHits == 0 { + t.Fatal("loginHelper never typed into desc:login_email on sample-app hierarchy") } - if typePhoneHits == 0 { - t.Fatal("typePhone never fired on sample-app hierarchy") + if tapSubmitHits == 0 { + t.Fatal("adversarialLogin never tapped desc:login_submit on sample-app hierarchy") } } @@ -108,12 +124,7 @@ func TestSampleAppSpecPropertiesEvaluate(t *testing.T) { if err != nil { t.Fatal(err) } - snapshots := Snapshots{ - "route": json.RawMessage(`"login"`), - "logged_in": json.RawMessage(`false`), - "account_count": json.RawMessage(`0`), - } - if err := v.PushSnapshot(SnapshotInput{Snapshots: snapshots, Tree: tree}); err != nil { + if err := v.PushSnapshot(SnapshotInput{Snapshots: loginSnapshots(), Tree: tree}); err != nil { t.Fatal(err) } verdicts := v.EvaluateProperties() @@ -123,8 +134,14 @@ func TestSampleAppSpecPropertiesEvaluate(t *testing.T) { if verdicts["noUncaughtExceptions"] != ltl.VerdictHolds { t.Errorf("noUncaughtExceptions: got %v, want holds", verdicts["noUncaughtExceptions"]) } - // Liveness: eventuallyLoggedIn hasn't resolved yet. - if verdicts["eventuallyLoggedIn"] != ltl.VerdictPending { - t.Errorf("eventuallyLoggedIn: got %v, want pending", verdicts["eventuallyLoggedIn"]) + if verdicts["authStatusIsKnown"] != ltl.VerdictHolds { + t.Errorf("authStatusIsKnown: got %v, want holds", verdicts["authStatusIsKnown"]) + } + if verdicts["routeIsKnown"] != ltl.VerdictHolds { + t.Errorf("routeIsKnown: got %v, want holds", verdicts["routeIsKnown"]) + } + // Liveness: loginReachable hasn't resolved yet. + if verdicts["loginReachable"] != ltl.VerdictPending { + t.Errorf("loginReachable: got %v, want pending", verdicts["loginReachable"]) } } diff --git a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt index f498e4e..370303e 100644 --- a/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/uatu/sidecar/DriverBackend.kt @@ -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 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( + "]*\\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("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + internal val KEY_MAP: Map = 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 == "") return null + return parseFocusedText(xml) + } + @Volatile var lastSwipe: SwipeRecord? = null private set @Volatile var lastKey: String? = null diff --git a/sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt b/sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt new file mode 100644 index 0000000..ebeb1da --- /dev/null +++ b/sidecar/src/test/kotlin/dev/uatu/sidecar/InputTextClearTest.kt @@ -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 = """ + + + + + """.trimIndent() + + assertEquals("old@value", StubDriverBackend.parseFocusedText(xml)) + } + + @Test fun returnsEmptyWhenFocusedNodeHasNoTextAttribute() { + val xml = """""" + + assertEquals("", StubDriverBackend.parseFocusedText(xml)) + } + + @Test fun returnsNullWhenNoFocusedNode() { + val xml = """""" + + assertNull(StubDriverBackend.parseFocusedText(xml)) + } + + @Test fun decodesXmlEntitiesInAttribute() { + val xml = """""" + + assertEquals("a&b + + + + """.trimIndent() + + assertEquals("first", StubDriverBackend.parseFocusedText(xml)) + } +}