fix: address the Maps search box by id, and re-prove Phase 5

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>
This commit is contained in:
2026-10-17 09:12:01 +11:00
parent a7c07c645e
commit 3ef11da808
9 changed files with 206 additions and 26 deletions

View File

@@ -99,6 +99,59 @@ func TestPickBestMatchRejectsDisabledOnly(t *testing.T) {
}
}
// 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'",