mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user