fix(runner): surface focus-tap errors in InputText (#12)

* fix(runner): surface focus-tap errors in InputText action

A failed Tap/TapSelector before InputText was swallowed, so text typed
into the wrong field (or no field) still reported success. Return the
error so the step fails explicitly.

* feat(sample-app): add username EditText and snapshot

Gives the spec a real EditText target (content-desc: username_field)
so the InputText action path can be exercised end-to-end. The typed
value is mirrored into MainActivity.username and surfaced as the
"username" snapshot for spec assertions.

* feat(sample-app): exercise InputText action against username field

Adds typeUsername action and usernameNeverShrinks property to the
sample spec, and extends the integration test to assert the bundled
spec emits an InputText(desc:username_field, "alice") action and that
the property correctly violates when a snapshot reports a shorter
string.
This commit is contained in:
pj authored and GitHub committed 2026-04-18 16:51:35 +07:00
1 parent b457e22569
commit 16e55086d8
6 files changed
+101 -8

No files matched your search

@@ -3,14 +3,18 @@ package dev.uatu.sample
import android.app.Activity import android.app.Activity
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
import android.view.Gravity import android.view.Gravity
import android.widget.Button import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
class MainActivity : Activity() { class MainActivity : Activity() {
companion object { companion object {
@Volatile var clickCount: Int = 0 @Volatile var clickCount: Int = 0
@Volatile var username: String = ""
} }
private lateinit var label: TextView private lateinit var label: TextView
@@ -43,6 +47,29 @@ class MainActivity : Activity() {
} }
layout.addView(button) 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) setContentView(layout)
} }
} }
@@ -9,6 +9,7 @@ class SampleApplication : Application() {
Uatu.start(this) Uatu.start(this)
Uatu.extract("app_state") { "running" } Uatu.extract("app_state") { "running" }
Uatu.extract("click_count") { MainActivity.clickCount } Uatu.extract("click_count") { MainActivity.clickCount }
Uatu.extract("username") { MainActivity.username }
Uatu.extract("uptime_millis") { System.currentTimeMillis() - startedAt } Uatu.extract("uptime_millis") { System.currentTimeMillis() - startedAt }
} }
+17 -1
View File
@@ -4,6 +4,7 @@ import {
actions, actions,
weighted, weighted,
Tap, Tap,
InputText,
taps, taps,
swipes, swipes,
} from "@uatu/spec"; } from "@uatu/spec";
@@ -16,9 +17,13 @@ const appState = extract<string>(
const clickCount = extract<number>( const clickCount = extract<number>(
(state) => (state.snapshots.click_count as number) ?? 0, (state) => (state.snapshots.click_count as number) ?? 0,
); );
const username = extract<string>(
(state) => (state.snapshots.username as string) ?? "",
);
// ── UI elements ──────────────────────────────────────────────── // ── UI elements ────────────────────────────────────────────────
const clickButton = extract((state) => state.ax.find("text:Click me")); const clickButton = extract((state) => state.ax.find("text:Click me"));
const usernameField = extract((state) => state.ax.find("desc:username_field"));
// ── Properties ───────────────────────────────────────────────── // ── Properties ─────────────────────────────────────────────────
export const properties = { export const properties = {
@@ -28,6 +33,10 @@ export const properties = {
const previous = clickCount.previous; const previous = clickCount.previous;
return previous === undefined || clickCount.current >= previous; return previous === undefined || clickCount.current >= previous;
}), }),
usernameNeverShrinks: always(() => {
const previous = username.previous;
return previous === undefined || username.current.length >= previous.length;
}),
}; };
// ── Actions ──────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────
@@ -35,8 +44,15 @@ const tapClickMe = actions(() => {
return clickButton.current ? [Tap({ on: clickButton.current })] : []; return clickButton.current ? [Tap({ on: clickButton.current })] : [];
}); });
const typeUsername = actions(() => {
return usernameField.current
? [InputText({ into: usernameField.current, text: "alice" })]
: [];
});
export const actionsRoot = weighted( export const actionsRoot = weighted(
[100, tapClickMe], [50, tapClickMe],
[50, typeUsername],
[10, taps], [10, taps],
[2, swipes], [2, swipes],
); );
+6 -2
View File
@@ -198,9 +198,13 @@ func applyAction(ctx context.Context, drv driver.Driver, action verifier.Action,
return drv.Tap(ctx, x, y) return drv.Tap(ctx, x, y)
case verifier.ActionKindInputText: case verifier.ActionKindInputText:
if x, y, ok := resolveCoordinates(action, tree); ok { 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 != "" { } 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) return drv.InputText(ctx, action.Text)
default: default:
+30
View File
@@ -3,6 +3,7 @@ package runner
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net" "net"
"os" "os"
"path/filepath" "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 { func mustNewVerifier(t *testing.T) *verifier.Verifier {
t.Helper() t.Helper()
verifierInstance, err := verifier.New() verifierInstance, err := verifier.New()
+20 -5
View File
@@ -16,6 +16,8 @@ const sampleAppHierarchyXML = `<?xml version="1.0" encoding="UTF-8"?>
<node index="0" class="android.widget.LinearLayout" bounds="[64,96][1016,2336]"> <node index="0" class="android.widget.LinearLayout" bounds="[64,96][1016,2336]">
<node index="0" class="android.widget.TextView" text="Clicks: 0" bounds="[100,200][900,300]" /> <node index="0" class="android.widget.TextView" text="Clicks: 0" bounds="[100,200][900,300]" />
<node index="1" class="android.widget.Button" text="Click me" clickable="true" enabled="true" bounds="[400,800][680,920]" /> <node index="1" class="android.widget.Button" text="Click me" clickable="true" enabled="true" bounds="[400,800][680,920]" />
<node index="2" class="android.widget.TextView" text="Username: " bounds="[100,1000][900,1080]" />
<node index="3" class="android.widget.EditText" content-desc="username_field" clickable="true" enabled="true" bounds="[100,1200][900,1320]" />
</node> </node>
</node> </node>
</hierarchy>` </hierarchy>`
@@ -63,18 +65,25 @@ func TestSampleAppSpecTapsClickMe(t *testing.T) {
} }
tapHits := 0 tapHits := 0
for range 200 { inputHits := 0
for range 400 {
action, err := v.NextAction() action, err := v.NextAction()
if err != nil { if err != nil {
continue continue
} }
if action.Kind == ActionKindTap && action.On == "text:Click me" { switch {
case action.Kind == ActionKindTap && action.On == "text:Click me":
tapHits++ tapHits++
case action.Kind == ActionKindInputText && action.On == "desc:username_field" && action.Text == "alice":
inputHits++
} }
} }
if tapHits == 0 { if tapHits == 0 {
t.Fatal("tapClickMe never fired on sample-app hierarchy") 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 // TestSampleAppSpecPropertiesHold checks the three properties declared in the
@@ -88,31 +97,37 @@ func TestSampleAppSpecPropertiesHold(t *testing.T) {
steps := []struct { steps := []struct {
appState string appState string
clickCount int clickCount int
username string
want map[string]ltl.Verdict want map[string]ltl.Verdict
}{ }{
{"running", 0, map[string]ltl.Verdict{ {"running", 0, "", map[string]ltl.Verdict{
"appIsRunning": ltl.VerdictHolds, "appIsRunning": ltl.VerdictHolds,
"clickCountNonNegative": ltl.VerdictHolds, "clickCountNonNegative": ltl.VerdictHolds,
"clickCountNeverDecreases": ltl.VerdictHolds, "clickCountNeverDecreases": ltl.VerdictHolds,
"usernameNeverShrinks": ltl.VerdictHolds,
}}, }},
{"running", 5, map[string]ltl.Verdict{ {"running", 5, "alice", map[string]ltl.Verdict{
"appIsRunning": ltl.VerdictHolds, "appIsRunning": ltl.VerdictHolds,
"clickCountNonNegative": ltl.VerdictHolds, "clickCountNonNegative": ltl.VerdictHolds,
"clickCountNeverDecreases": ltl.VerdictHolds, "clickCountNeverDecreases": ltl.VerdictHolds,
"usernameNeverShrinks": ltl.VerdictHolds,
}}, }},
{"running", 3, map[string]ltl.Verdict{ {"running", 3, "al", map[string]ltl.Verdict{
"appIsRunning": ltl.VerdictHolds, "appIsRunning": ltl.VerdictHolds,
"clickCountNonNegative": ltl.VerdictHolds, "clickCountNonNegative": ltl.VerdictHolds,
"clickCountNeverDecreases": ltl.VerdictViolated, "clickCountNeverDecreases": ltl.VerdictViolated,
"usernameNeverShrinks": ltl.VerdictViolated,
}}, }},
} }
for index, step := range steps { for index, step := range steps {
stateRaw, _ := json.Marshal(step.appState) stateRaw, _ := json.Marshal(step.appState)
countRaw, _ := json.Marshal(step.clickCount) countRaw, _ := json.Marshal(step.clickCount)
usernameRaw, _ := json.Marshal(step.username)
if err := v.PushSnapshot(Snapshots{ if err := v.PushSnapshot(Snapshots{
"app_state": stateRaw, "app_state": stateRaw,
"click_count": countRaw, "click_count": countRaw,
"username": usernameRaw,
}, nil); err != nil { }, nil); err != nil {
t.Fatalf("step %d: %v", index, err) t.Fatalf("step %d: %v", index, err)
} }