diff --git a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/MainActivity.kt b/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/MainActivity.kt index 9fe9d02..d59f691 100644 --- a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/MainActivity.kt +++ b/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/MainActivity.kt @@ -3,14 +3,18 @@ package dev.uatu.sample import android.app.Activity import android.graphics.Color import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher import android.view.Gravity import android.widget.Button +import android.widget.EditText import android.widget.LinearLayout import android.widget.TextView class MainActivity : Activity() { companion object { @Volatile var clickCount: Int = 0 + @Volatile var username: String = "" } private lateinit var label: TextView @@ -43,6 +47,29 @@ class MainActivity : Activity() { } layout.addView(button) + val usernameLabel = TextView(this).apply { + text = "Username: " + textSize = 20f + setTextColor(Color.BLACK) + gravity = Gravity.CENTER + } + layout.addView(usernameLabel) + + val usernameField = EditText(this).apply { + hint = "username" + contentDescription = "username_field" + textSize = 18f + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + username = s?.toString() ?: "" + usernameLabel.text = "Username: $username" + } + }) + } + layout.addView(usernameField) + setContentView(layout) } } diff --git a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt b/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt index 99a6e91..be66cb9 100644 --- a/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt +++ b/examples/sample-app/android/src/main/kotlin/dev/uatu/sample/SampleApplication.kt @@ -9,6 +9,7 @@ class SampleApplication : Application() { Uatu.start(this) Uatu.extract("app_state") { "running" } Uatu.extract("click_count") { MainActivity.clickCount } + Uatu.extract("username") { MainActivity.username } Uatu.extract("uptime_millis") { System.currentTimeMillis() - startedAt } } diff --git a/examples/sample-app/spec.ts b/examples/sample-app/spec.ts index 4db2432..52c20af 100644 --- a/examples/sample-app/spec.ts +++ b/examples/sample-app/spec.ts @@ -4,6 +4,7 @@ import { actions, weighted, Tap, + InputText, taps, swipes, } from "@uatu/spec"; @@ -16,9 +17,13 @@ const appState = extract( const clickCount = extract( (state) => (state.snapshots.click_count as number) ?? 0, ); +const username = extract( + (state) => (state.snapshots.username as string) ?? "", +); // ── UI elements ──────────────────────────────────────────────── const clickButton = extract((state) => state.ax.find("text:Click me")); +const usernameField = extract((state) => state.ax.find("desc:username_field")); // ── Properties ───────────────────────────────────────────────── export const properties = { @@ -28,6 +33,10 @@ export const properties = { const previous = clickCount.previous; return previous === undefined || clickCount.current >= previous; }), + usernameNeverShrinks: always(() => { + const previous = username.previous; + return previous === undefined || username.current.length >= previous.length; + }), }; // ── Actions ──────────────────────────────────────────────────── @@ -35,8 +44,15 @@ const tapClickMe = actions(() => { return clickButton.current ? [Tap({ on: clickButton.current })] : []; }); +const typeUsername = actions(() => { + return usernameField.current + ? [InputText({ into: usernameField.current, text: "alice" })] + : []; +}); + export const actionsRoot = weighted( - [100, tapClickMe], + [50, tapClickMe], + [50, typeUsername], [10, taps], [2, swipes], ); diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 50a3bcc..2ce99b3 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -198,9 +198,13 @@ func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action, return drv.Tap(ctx, x, y) case verifier.ActionKindInputText: if x, y, ok := resolveCoordinates(action, tree); ok { - _ = drv.Tap(ctx, x, y) + if err := drv.Tap(ctx, x, y); err != nil { + return err + } } else if action.On != "" { - _ = drv.TapSelector(ctx, action.On) + if err := drv.TapSelector(ctx, action.On); err != nil { + return err + } } return drv.InputText(ctx, action.Text) default: diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index fc241c1..98e6b91 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -3,6 +3,7 @@ package runner import ( "context" "encoding/json" + "errors" "net" "os" "path/filepath" @@ -234,6 +235,35 @@ func TestRunner_RecordsScreenFieldFromSnapshot(t *testing.T) { } } +func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) { + t.Run("selector focus tap fails", func(t *testing.T) { + driverMock := mockdriver.New() + driverMock.Failures[mockdriver.ActionTapSelector] = errors.New("adb unreachable") + action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"} + + err := applyAction(context.Background(), driverMock, action, nil) + if err == nil { + t.Fatalf("expected focus tap failure to surface, got nil") + } + if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") { + t.Errorf("InputText must not run after focus tap failed: %v", driverMock.Actions()) + } + }) + t.Run("coordinate focus tap fails", func(t *testing.T) { + driverMock := mockdriver.New() + driverMock.Failures[mockdriver.ActionTap] = errors.New("tap driver error") + action := verifier.Action{Kind: verifier.ActionKindInputText, X: 10, Y: 20, Text: "alice"} + + err := applyAction(context.Background(), driverMock, action, nil) + if err == nil { + t.Fatalf("expected focus tap failure to surface, got nil") + } + if containsAction(driverMock.Actions(), mockdriver.ActionInputText, "") { + t.Errorf("InputText must not run after focus tap failed: %v", driverMock.Actions()) + } + }) +} + func mustNewVerifier(t *testing.T) *verifier.Verifier { t.Helper() verifierInstance, err := verifier.New() diff --git a/internal/verifier/spec_integration_test.go b/internal/verifier/spec_integration_test.go index 74722bf..b062324 100644 --- a/internal/verifier/spec_integration_test.go +++ b/internal/verifier/spec_integration_test.go @@ -16,6 +16,8 @@ const sampleAppHierarchyXML = ` + + ` @@ -63,18 +65,25 @@ func TestSampleAppSpecTapsClickMe(t *testing.T) { } tapHits := 0 - for range 200 { + inputHits := 0 + for range 400 { action, err := v.NextAction() if err != nil { continue } - if action.Kind == ActionKindTap && action.On == "text:Click me" { + switch { + case action.Kind == ActionKindTap && action.On == "text:Click me": tapHits++ + case action.Kind == ActionKindInputText && action.On == "desc:username_field" && action.Text == "alice": + inputHits++ } } if tapHits == 0 { t.Fatal("tapClickMe never fired on sample-app hierarchy") } + if inputHits == 0 { + t.Fatal("typeUsername never fired on sample-app hierarchy") + } } // TestSampleAppSpecPropertiesHold checks the three properties declared in the @@ -88,31 +97,37 @@ func TestSampleAppSpecPropertiesHold(t *testing.T) { steps := []struct { appState string clickCount int + username string want map[string]ltl.Verdict }{ - {"running", 0, map[string]ltl.Verdict{ + {"running", 0, "", map[string]ltl.Verdict{ "appIsRunning": ltl.VerdictHolds, "clickCountNonNegative": ltl.VerdictHolds, "clickCountNeverDecreases": ltl.VerdictHolds, + "usernameNeverShrinks": ltl.VerdictHolds, }}, - {"running", 5, map[string]ltl.Verdict{ + {"running", 5, "alice", map[string]ltl.Verdict{ "appIsRunning": ltl.VerdictHolds, "clickCountNonNegative": ltl.VerdictHolds, "clickCountNeverDecreases": ltl.VerdictHolds, + "usernameNeverShrinks": ltl.VerdictHolds, }}, - {"running", 3, map[string]ltl.Verdict{ + {"running", 3, "al", map[string]ltl.Verdict{ "appIsRunning": ltl.VerdictHolds, "clickCountNonNegative": ltl.VerdictHolds, "clickCountNeverDecreases": ltl.VerdictViolated, + "usernameNeverShrinks": ltl.VerdictViolated, }}, } for index, step := range steps { stateRaw, _ := json.Marshal(step.appState) countRaw, _ := json.Marshal(step.clickCount) + usernameRaw, _ := json.Marshal(step.username) if err := v.PushSnapshot(Snapshots{ "app_state": stateRaw, "click_count": countRaw, + "username": usernameRaw, }, nil); err != nil { t.Fatalf("step %d: %v", index, err) }