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

@@ -324,7 +324,7 @@ func executeStep(r *adb.Runner, rec *Recipe, step Step, index int, params map[st
if err != nil {
return err
}
matches := h.FindText(args[0], step.Exact)
matches := h.Find(args[0], step.Exact)
if len(matches) == 0 {
sr.Detail = fmt.Sprintf("%q not on screen, skipped", args[0])
return nil
@@ -410,7 +410,8 @@ func executeStep(r *adb.Runner, rec *Recipe, step Step, index int, params map[st
return sr
}
// tapByText finds an element by text and taps its centre (FR-4).
// tapByText finds an element by text and taps its centre (FR-4). The selector
// may name a resource id with the "id:" prefix.
//
// The element is looked up twice: once to wait for it, and again immediately
// before the tap, so the coordinates come from the newest dump. On a busy
@@ -423,9 +424,9 @@ func tapByText(r *adb.Runner, rec *Recipe, query string, exact bool, timeout tim
if err != nil {
return err
}
matches := h.FindText(query, exact)
matches := h.Find(query, exact)
if len(matches) == 0 {
return describeFailure(r, rec, fmt.Errorf("element with text %q disappeared before the tap", query))
return describeFailure(r, rec, fmt.Errorf("element matching %q disappeared before the tap", query))
}
node, err := ui.TapNode(r, matches)
if err != nil {

View File

@@ -32,6 +32,10 @@
// - launch start the recipe's app
// - foreground 30s wait until the recipe's app is in front
// - wait "Search" 30s wait for an element whose text matches
// - wait id:search_box 30s wait for an element by resource id. The short
// name after the "/" is enough. Use this when a
// label rotates (the Maps search box does); it is
// still a named match, never a coordinate (FR-4).
// - wait exact "Pause" 60s retry 3
// if it never appears, re-run the step above and
// wait again (a busy phone can drop a tap)

View File

@@ -129,7 +129,8 @@ func pickBestMatch(matches []*Node) (*Node, error) {
}
// TapText finds an element by its text (or content description) and taps its
// centre. This is FR-4: text match first, coordinates derived from bounds.
// centre. This is FR-4: a named match first, coordinates derived from bounds.
// A selector may also name a resource id with the "id:" prefix.
//
// It fails loudly when no element matches and never falls back to tapping a
// guessed coordinate.
@@ -141,9 +142,9 @@ func TapText(r *adb.Runner, query string, exact bool) (*Node, error) {
if err != nil {
return nil, err
}
matches := h.FindText(query, exact)
matches := h.Find(query, exact)
if len(matches) == 0 {
return nil, fmt.Errorf("element with text %q not found on screen; refusing to tap blindly", query)
return nil, fmt.Errorf("element matching %q not found on screen; refusing to tap blindly", query)
}
return TapNode(r, matches)
}
@@ -172,7 +173,8 @@ func TapNode(r *adb.Runner, matches []*Node) (*Node, error) {
// WaitForText polls the screen until an element matching query appears, then
// returns the best tappable match. Use it after an action that loads a new
// screen; apps do not draw instantly.
// screen; apps do not draw instantly. A selector may name a resource id with
// the "id:" prefix.
func WaitForText(r *adb.Runner, query string, exact bool, timeout time.Duration) (*Node, error) {
if timeout <= 0 {
timeout = 10 * time.Second
@@ -182,7 +184,7 @@ func WaitForText(r *adb.Runner, query string, exact bool, timeout time.Duration)
for time.Now().Before(deadline) {
h, err := Dump(r)
if err == nil {
matches := h.FindText(query, exact)
matches := h.Find(query, exact)
if len(matches) > 0 {
sawMatch = true
if n, perr := pickBestMatch(matches); perr == nil {
@@ -193,14 +195,15 @@ func WaitForText(r *adb.Runner, query string, exact bool, timeout time.Duration)
time.Sleep(PollInterval)
}
if sawMatch {
return nil, fmt.Errorf("text %q is present but never became a usable tap target within %s (it is off screen or disabled)", query, timeout)
return nil, fmt.Errorf("element matching %q is present but never became a usable tap target within %s (it is off screen or disabled)", query, timeout)
}
return nil, fmt.Errorf("text %q did not appear within %s", query, timeout)
return nil, fmt.Errorf("element matching %q did not appear within %s", query, timeout)
}
// WaitForNoText polls the screen until no element matches query. Recipes use it
// to wait for one list to be replaced by another, for example a search result
// list replacing the "Recents" list.
// list replacing the "Recents" list. A selector may name a resource id with the
// "id:" prefix.
func WaitForNoText(r *adb.Runner, query string, exact bool, timeout time.Duration) error {
if timeout <= 0 {
timeout = 10 * time.Second
@@ -208,12 +211,12 @@ func WaitForNoText(r *adb.Runner, query string, exact bool, timeout time.Duratio
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
h, err := Dump(r)
if err == nil && len(h.FindText(query, exact)) == 0 {
if err == nil && len(h.Find(query, exact)) == 0 {
return nil
}
time.Sleep(PollInterval)
}
return fmt.Errorf("text %q was still on screen after %s", query, timeout)
return fmt.Errorf("element matching %q was still on screen after %s", query, timeout)
}
// ScreenSize reads the physical screen size.

View File

@@ -103,16 +103,56 @@ func shortID(id string) string {
return id
}
// IDSelector marks a selector that matches a resource id rather than text. A
// step that names "id:search_omnibox_text_box" finds the element with that
// resource id, whatever its visible label happens to be.
//
// This exists because some labels are not stable: the Google Maps search box
// carries a rotating hint as its content description ("Search here", "Try
// petrol stations, ATMs", ...), so a recipe that matched that text broke when
// the phone picked a different hint. The resource id does not rotate.
const IDSelector = "id:"
// FindText returns nodes whose text or content description matches query.
// When exact is false the match is case-insensitive and substring-based.
func (h *Hierarchy) FindText(query string, exact bool) []*Node {
return h.find(query, exact, false)
}
// FindID returns nodes whose resource id matches query. When exact is false the
// match is case-insensitive and substring-based, so the short name
// "search_omnibox_text_box" matches the full
// "com.google.android.apps.maps:id/search_omnibox_text_box".
func (h *Hierarchy) FindID(query string, exact bool) []*Node {
return h.find(query, exact, true)
}
// Find returns nodes matching a recipe selector. A selector that starts with
// "id:" is matched against the resource id; any other selector is matched
// against text and content description (FR-4).
func (h *Hierarchy) Find(query string, exact bool) []*Node {
if id, ok := strings.CutPrefix(query, IDSelector); ok {
return h.FindID(id, exact)
}
return h.FindText(query, exact)
}
func (h *Hierarchy) find(query string, exact, byID bool) []*Node {
if query == "" {
return nil
}
needle := strings.ToLower(query)
var out []*Node
h.Walk(func(n *Node) {
for _, candidate := range []string{n.Text, n.ContentDesc} {
var candidates []string
if byID {
if n.ResourceID != "" {
candidates = []string{shortID(n.ResourceID), n.ResourceID}
}
} else {
candidates = []string{n.Text, n.ContentDesc}
}
for _, candidate := range candidates {
if candidate == "" {
continue
}

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'",