The Maps omnibox hint rotates: it read "Search here" in the Phase 5 run and "Try petrol stations, ATMs" in the AUD-128 re-verification, so the recipe's text match on it failed. The resource id does not rotate. - internal/ui: add Find/FindID and the "id:" selector; Find dispatches an id: selector to the resource id and everything else to text as before. - internal/recipe: wait/tap/wait_gone/tap_if and the tap helper use Find, so a recipe may name a resource id. Guards stay text-only. - recipes/maps_navigate.recipe: wait/tap id:search_omnibox_text_box. - tests for the id selector; docs and README updated. Co-Authored-By: Paperclip <noreply@paperclip.ing>
172 lines
6.2 KiB
Go
172 lines
6.2 KiB
Go
package ui
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const sampleDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
|
<hierarchy rotation="0">
|
|
<node index="0" text="" class="android.widget.FrameLayout" package="com.tailscale.ipn" clickable="false" enabled="true" scrollable="false" bounds="[0,0][1080,2400]">
|
|
<node index="0" text="samuelrolfe@gmail.com" class="android.widget.TextView" package="com.tailscale.ipn" clickable="false" enabled="true" bounds="[221,155][798,217]"/>
|
|
<node index="1" text="Connected" class="android.widget.TextView" package="com.tailscale.ipn" clickable="false" enabled="true" bounds="[221,217][453,277]"/>
|
|
<node index="2" text="" content-desc="Navigate up" class="android.widget.ImageButton" package="com.tailscale.ipn" clickable="true" enabled="true" bounds="[42,121][189,268]"/>
|
|
</node>
|
|
<node index="1" text="Network and internet" class="android.widget.TextView" package="com.android.settings" clickable="true" enabled="true" bounds="[221,663][748,727]">
|
|
<node index="0" text="Network and internet" class="android.widget.TextView" package="com.android.settings" clickable="false" enabled="true" bounds="[221,663][748,727]"/>
|
|
</node>
|
|
<node index="2" text="Disabled thing" class="android.widget.TextView" package="com.android.settings" clickable="true" enabled="false" bounds="[0,0][10,10]"/>
|
|
</hierarchy>`
|
|
|
|
func parseSample(t *testing.T) *Hierarchy {
|
|
t.Helper()
|
|
h, err := parseHierarchy([]byte(sampleDump))
|
|
if err != nil {
|
|
t.Fatalf("parseHierarchy: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func TestBoundsAndCenter(t *testing.T) {
|
|
n := &Node{Bounds: "[221,155][798,217]"}
|
|
r, err := n.BoundsRect()
|
|
if err != nil {
|
|
t.Fatalf("BoundsRect: %v", err)
|
|
}
|
|
if r.X1 != 221 || r.Y1 != 155 || r.X2 != 798 || r.Y2 != 217 {
|
|
t.Fatalf("unexpected rect %+v", r)
|
|
}
|
|
cx, cy, err := n.Center()
|
|
if err != nil {
|
|
t.Fatalf("Center: %v", err)
|
|
}
|
|
if cx != (221+798)/2 || cy != (155+217)/2 {
|
|
t.Fatalf("unexpected centre (%d,%d)", cx, cy)
|
|
}
|
|
}
|
|
|
|
func TestBoundsRejectsGarbage(t *testing.T) {
|
|
for _, bad := range []string{"", "nope", "[1,2][3,4]junk", "[10,10][10,20]"} {
|
|
if _, err := (&Node{Bounds: bad}).BoundsRect(); err == nil {
|
|
t.Fatalf("expected error for bounds %q", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFindTextExactAndSubstring(t *testing.T) {
|
|
h := parseSample(t)
|
|
|
|
if got := len(h.FindText("Connected", true)); got != 1 {
|
|
t.Fatalf("exact match: want 1, got %d", got)
|
|
}
|
|
// Substring is case-insensitive and matches more than one node.
|
|
if got := len(h.FindText("network", false)); got != 2 {
|
|
t.Fatalf("substring match: want 2 (parent + child), got %d", got)
|
|
}
|
|
// Content descriptions count as text.
|
|
matches := h.FindText("Navigate up", true)
|
|
if len(matches) != 1 || !matches[0].Clickable {
|
|
t.Fatalf("content-desc match failed: %+v", matches)
|
|
}
|
|
if got := h.FindText("does not exist", false); got != nil {
|
|
t.Fatalf("expected no matches, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestPickBestMatchPrefersClickable(t *testing.T) {
|
|
h := parseSample(t)
|
|
matches := h.FindText("network", false)
|
|
if len(matches) != 2 {
|
|
t.Fatalf("want 2 matches, got %d", len(matches))
|
|
}
|
|
best, err := pickBestMatch(matches)
|
|
if err != nil {
|
|
t.Fatalf("pickBestMatch: %v", err)
|
|
}
|
|
if !best.Clickable {
|
|
t.Fatalf("expected a clickable match, got %s", best.Label())
|
|
}
|
|
}
|
|
|
|
func TestPickBestMatchRejectsDisabledOnly(t *testing.T) {
|
|
h := parseSample(t)
|
|
matches := h.FindText("Disabled thing", true)
|
|
if len(matches) != 1 {
|
|
t.Fatalf("want 1 match, got %d", len(matches))
|
|
}
|
|
if _, err := pickBestMatch(matches); err == nil {
|
|
t.Fatal("expected an error when the only match is disabled")
|
|
}
|
|
}
|
|
|
|
// The Maps search box keeps a stable resource id but a rotating label, so it is
|
|
// addressed with the "id:" selector. This covers the full and short id, the
|
|
// prefix dispatch, and that a plain selector is still matched by text.
|
|
const sampleSelectorDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
|
<hierarchy rotation="0">
|
|
<node index="0" text="" resource-id="com.google.android.apps.maps:id/search_omnibox_text_box" class="android.widget.EditText" package="com.google.android.apps.maps" content-desc="Try petrol stations, ATMs" clickable="true" enabled="true" bounds="[31,142][663,268]"/>
|
|
<node index="1" text="Search" resource-id="com.example:id/other" class="android.widget.TextView" package="com.example" clickable="true" enabled="true" bounds="[0,0][10,10]"/>
|
|
</hierarchy>`
|
|
|
|
func parseSelectorSample(t *testing.T) *Hierarchy {
|
|
t.Helper()
|
|
h, err := parseHierarchy([]byte(sampleSelectorDump))
|
|
if err != nil {
|
|
t.Fatalf("parseHierarchy: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
func TestFindIDSelector(t *testing.T) {
|
|
h := parseSelectorSample(t)
|
|
|
|
// Full resource id, and the short name after the "/".
|
|
for _, sel := range []string{
|
|
"id:search_omnibox_text_box",
|
|
"id:com.google.android.apps.maps:id/search_omnibox_text_box",
|
|
} {
|
|
matches := h.Find(sel, false)
|
|
if len(matches) != 1 {
|
|
t.Fatalf("Find(%q): want 1 match, got %d", sel, len(matches))
|
|
}
|
|
if matches[0].Class != "android.widget.EditText" {
|
|
t.Fatalf("Find(%q) matched %q", sel, matches[0].Class)
|
|
}
|
|
}
|
|
|
|
// Exact matching of the short id.
|
|
if got := len(h.Find("id:search_omnibox_text_box", true)); got != 1 {
|
|
t.Fatalf("exact id: want 1, got %d", got)
|
|
}
|
|
// A plain selector is not diverted to the id match.
|
|
if got := len(h.Find("search_omnibox_text_box", false)); got != 0 {
|
|
t.Fatalf("plain text must not match a resource id, got %d", got)
|
|
}
|
|
// Text still works through Find.
|
|
if got := len(h.Find("Search", true)); got != 1 {
|
|
t.Fatalf("text via Find: want 1, got %d", got)
|
|
}
|
|
// An unknown id is a clean miss, not a fallthrough to text.
|
|
if got := h.Find("id:no_such_id", false); len(got) != 0 {
|
|
t.Fatalf("unknown id: want 0, got %d", len(got))
|
|
}
|
|
}
|
|
|
|
func TestEscapeInputText(t *testing.T) {
|
|
cases := map[string]string{
|
|
"hello world": "'hello%sworld'",
|
|
"Rolling Stones": "'Rolling%sStones'",
|
|
"it's": `'it'\''s'`,
|
|
}
|
|
for in, want := range cases {
|
|
if got := escapeInputText(in); got != want {
|
|
t.Errorf("escapeInputText(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
// A single quote must not be able to break out of the quoting.
|
|
got := escapeInputText("a'; rm -rf /; 'b")
|
|
if strings.Count(got, "'")%2 != 0 {
|
|
t.Fatalf("unbalanced quotes in %q", got)
|
|
}
|
|
}
|