diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index a79d9e0..fa2b0a4 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -421,6 +421,24 @@ func TestDoubleTapsBuiltin_TargetsClickable(t *testing.T) { if action.X != 50 || action.Y != 60 { t.Errorf("coords = (%d,%d), want (50,60) at SubmitButton center", action.X, action.Y) } + // Action-gated properties read lastAction.on to tell which target the + // chooser hit. An empty On reduces those properties to vacuously-true and + // they never fire on the real tap event. + if action.On == "" { + t.Fatal("On must be populated so action-gated properties can identify the target") + } + if !strings.Contains(action.On, "SubmitButton") { + t.Errorf("On = %q, want a selector containing SubmitButton", action.On) + } + resolved := tree.Find(action.On) + if resolved == nil { + t.Fatalf("On = %q does not resolve in the same tree", action.On) + } + rx, ry := resolved.Bounds.Center() + if rx != action.X || ry != action.Y { + t.Errorf("On %q resolves to (%d,%d), want the picked element's center (%d,%d)", + action.On, rx, ry, action.X, action.Y) + } } func TestDoubleTap_RoundTrip(t *testing.T) { diff --git a/internal/verifier/worker.go b/internal/verifier/worker.go index c8086ac..0d68f55 100644 --- a/internal/verifier/worker.go +++ b/internal/verifier/worker.go @@ -612,7 +612,48 @@ func (v *Verifier) generateRandomTapKind(kind ActionKind) (Action, error) { } picked := candidates[v.rng.IntN(len(candidates))] x, y := picked.Bounds.Center() - return Action{Kind: kind, X: x, Y: y}, nil + return Action{Kind: kind, On: selectorForElement(v.lastTree, picked), X: x, Y: y}, nil +} + +// selectorForElement builds a canonical "key:value" selector that resolves +// back to the given element via hierarchy.Tree.Find. Prefers resource-id (the +// testTag carrier on Android / accessibilityIdentifier on iOS), falling back +// to text and content-description so action-gated properties can still tell +// what was tapped even on legacy nodes without a testTag. Returns "" when no +// candidate selector uniquely resolves to the picked element so the runner +// keeps using the action's coordinates without re-routing to a sibling that +// shares the same id/text. +func selectorForElement(tree *hierarchy.Tree, element *hierarchy.Element) string { + if element == nil || tree == nil { + return "" + } + candidates := make([]string, 0, 4) + if element.ResourceID != "" { + candidates = append(candidates, "id:"+element.ResourceID) + } + // Some platforms surface the Compose testTag only in the attributes map + // (the sidecar doesn't always promote it to resource-id). Try the raw + // attribute keys before falling back to text-based selectors so an + // element with a unique testTag still gets identified. + for _, key := range []string{"testTag", "identifier", "accessibilityIdentifier"} { + if value := element.Attributes[key]; value != "" { + candidates = append(candidates, key+":"+value) + } + } + if element.Text != "" { + candidates = append(candidates, "text:"+element.Text) + } + if element.Description != "" { + candidates = append(candidates, "desc:"+element.Description) + } + for _, selector := range candidates { + resolved := tree.Find(selector) + if resolved == nil || resolved != element { + continue + } + return selector + } + return "" } // inputCorpus is the edge-case string pool the typing builtin draws from to