From d40f3acf1c6ff9283291083a193c06cc8505c87b Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Thu, 19 Feb 2026 17:41:16 -0300 Subject: [PATCH] Fix directional relative selectors to prefer closest element over deepest - Change below/above/leftOf/rightOf selection from DeepestMatchingElement to candidates[0] (closest by distance, clickable-preferred) - Keep deepest element behavior for non-directional filters (childOf, etc.) - Add regression tests for all three drivers (WDA, Appium, UIAutomator2) --- pkg/driver/appium/driver.go | 9 +++- pkg/driver/appium/driver_test.go | 68 +++++++++++++++++++++++ pkg/driver/uiautomator2/driver.go | 18 ++++++- pkg/driver/uiautomator2/driver_test.go | 46 ++++++++++++++++ pkg/driver/wda/driver.go | 9 +++- pkg/driver/wda/driver_test.go | 74 ++++++++++++++++++++++++++ 6 files changed, 220 insertions(+), 4 deletions(-) diff --git a/pkg/driver/appium/driver.go b/pkg/driver/appium/driver.go index 95ae8cc..41fb3b8 100644 --- a/pkg/driver/appium/driver.go +++ b/pkg/driver/appium/driver.go @@ -693,7 +693,14 @@ func (d *Driver) findElementRelativeWithElements(sel flow.Selector, allElements return nil, fmt.Errorf("no candidates after sorting") } - selected := SelectByIndex(candidates, sel.Index) + var selected *ParsedElement + if sel.Index == "" && (filterType == filterBelow || filterType == filterAbove || filterType == filterLeftOf || filterType == filterRightOf) { + // Directional filters sort candidates by distance. Pick the closest + // (first) element to match Maestro's .firstOrNull() behavior. + selected = candidates[0] + } else { + selected = SelectByIndex(candidates, sel.Index) + } // If element isn't clickable, try to find a clickable parent // This handles React Native pattern where text nodes aren't clickable but containers are diff --git a/pkg/driver/appium/driver_test.go b/pkg/driver/appium/driver_test.go index 3915f3d..c028d72 100644 --- a/pkg/driver/appium/driver_test.go +++ b/pkg/driver/appium/driver_test.go @@ -1264,6 +1264,74 @@ func TestFindElementRelativeWithElementsContainsDescendants(t *testing.T) { } } +// mockAppiumServerForRelativeDepthTest creates a server with elements that test +// distance vs. depth selection in directional relative selectors. +func mockAppiumServerForRelativeDepthTest() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + + if strings.HasSuffix(path, "/source") { + writeJSON(w, map[string]interface{}{ + "value": ` + + + + + + + + + + + + +`, + }) + return + } + + if strings.Contains(path, "/window/rect") { + writeJSON(w, map[string]interface{}{ + "value": map[string]interface{}{"width": 1080.0, "height": 2340.0, "x": 0.0, "y": 0.0}, + }) + return + } + + writeJSON(w, map[string]interface{}{"value": nil}) + })) +} + +// TestFindElementRelativePrefersClosestOverDeepest verifies that directional +// relative selectors pick the closest element by distance rather than the +// deepest in the DOM. +func TestFindElementRelativePrefersClosestOverDeepest(t *testing.T) { + server := mockAppiumServerForRelativeDepthTest() + defer server.Close() + driver := createTestAppiumDriver(server) + + source, _ := driver.client.Source() + elements, platform, _ := ParsePageSource(source) + + sel := flow.Selector{ + Below: &flow.Selector{Text: "Email Address"}, + } + + info, err := driver.findElementRelativeWithElements(sel, elements, platform) + if err != nil { + t.Fatalf("Expected success, got: %v", err) + } + if info == nil { + t.Fatal("Expected element info") + } + + // The closest element below "Email Address" (bottom at y=130) is the + // EditText at y=140, not the deeply-nested TextView at y=350. + if info.Bounds.Y != 140 { + t.Errorf("Expected element at y=140, got y=%d", info.Bounds.Y) + } +} + // TestFindElementRelativeWithNestedRelative tests nested relative selector func TestFindElementRelativeWithNestedRelative(t *testing.T) { server := mockAppiumServerForRelativeElements() diff --git a/pkg/driver/uiautomator2/driver.go b/pkg/driver/uiautomator2/driver.go index 89c8dc7..d110191 100644 --- a/pkg/driver/uiautomator2/driver.go +++ b/pkg/driver/uiautomator2/driver.go @@ -783,7 +783,14 @@ func (d *Driver) resolveRelativeSelector(sel flow.Selector) (*core.ElementInfo, // Prioritize clickable elements candidates = SortClickableFirst(candidates) - selected := SelectByIndex(candidates, sel.Index) + var selected *ParsedElement + if sel.Index == "" && (filterType == filterBelow || filterType == filterAbove || filterType == filterLeftOf || filterType == filterRightOf) { + // Directional filters sort candidates by distance. Pick the closest + // (first) element to match Maestro's .firstOrNull() behavior. + selected = candidates[0] + } else { + selected = SelectByIndex(candidates, sel.Index) + } // If element isn't clickable, try to find a clickable parent // This handles React Native pattern where text nodes aren't clickable but containers are @@ -872,7 +879,14 @@ func (d *Driver) findElementRelativeWithElements(sel flow.Selector, allElements // Prioritize clickable elements candidates = SortClickableFirst(candidates) - selected := SelectByIndex(candidates, sel.Index) + var selected *ParsedElement + if sel.Index == "" && (filterType == filterBelow || filterType == filterAbove || filterType == filterLeftOf || filterType == filterRightOf) { + // Directional filters sort candidates by distance. Pick the closest + // (first) element to match Maestro's .firstOrNull() behavior. + selected = candidates[0] + } else { + selected = SelectByIndex(candidates, sel.Index) + } // If element isn't clickable, try to find a clickable parent // This handles React Native pattern where text nodes aren't clickable but containers are diff --git a/pkg/driver/uiautomator2/driver_test.go b/pkg/driver/uiautomator2/driver_test.go index cc83be4..6d25ccf 100644 --- a/pkg/driver/uiautomator2/driver_test.go +++ b/pkg/driver/uiautomator2/driver_test.go @@ -2010,6 +2010,52 @@ func TestTapOnRelativeSelectorBelow(t *testing.T) { } } +// TestResolveRelativeSelectorPrefersClosestOverDeepest verifies that directional +// relative selectors pick the closest element by distance rather than the +// deepest in the DOM. +func TestResolveRelativeSelectorPrefersClosestOverDeepest(t *testing.T) { + pageSource := ` + + + + + + + + + + +` + + server := setupMockServer(t, map[string]func(w http.ResponseWriter, r *http.Request){ + "GET /source": func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]interface{}{"value": pageSource}) + }, + }) + defer server.Close() + + client := newMockHTTPClient(server.URL) + driver := New(client.Client, nil, nil) + + sel := flow.Selector{ + Below: &flow.Selector{Text: "Email Address"}, + } + + info, err := driver.resolveRelativeSelector(sel) + if err != nil { + t.Fatalf("Expected success, got: %v", err) + } + if info == nil { + t.Fatal("Expected element info") + } + + // The closest element below "Email Address" (bottom at y=130) is the + // EditText at y=140, not the deeply-nested TextView at y=350. + if info.Bounds.Y != 140 { + t.Errorf("Expected element at y=140, got y=%d", info.Bounds.Y) + } +} + func TestTapOnRelativeSelectorClickError(t *testing.T) { pageSource := ` diff --git a/pkg/driver/wda/driver.go b/pkg/driver/wda/driver.go index addfb0f..3932e88 100644 --- a/pkg/driver/wda/driver.go +++ b/pkg/driver/wda/driver.go @@ -655,7 +655,14 @@ func (d *Driver) resolveRelativeSelector(sel flow.Selector, allElements []*Parse // Prioritize clickable/interactive elements candidates = SortClickableFirst(candidates) - selected := SelectByIndex(candidates, sel.Index) + var selected *ParsedElement + if sel.Index == "" && (filterType == filterBelow || filterType == filterAbove || filterType == filterLeftOf || filterType == filterRightOf) { + // Directional filters sort candidates by distance. Pick the closest + // (first) element to match Maestro's .firstOrNull() behavior. + selected = candidates[0] + } else { + selected = SelectByIndex(candidates, sel.Index) + } return &core.ElementInfo{ Text: selected.Label, diff --git a/pkg/driver/wda/driver_test.go b/pkg/driver/wda/driver_test.go index facf7a2..b07b30a 100644 --- a/pkg/driver/wda/driver_test.go +++ b/pkg/driver/wda/driver_test.go @@ -1569,6 +1569,80 @@ func TestResolveRelativeSelectorContainsDescendants(t *testing.T) { } } +// mockWDAServerForRelativeDepthTest creates a server with elements that test +// distance vs. depth selection in directional relative selectors. +// The page source has a close TextField (depth 2) and a far-but-deeply-nested +// Link (depth 5) below the anchor. The correct behavior is to select the closer one. +func mockWDAServerForRelativeDepthTest() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + path := r.URL.Path + + if strings.HasSuffix(path, "/source") { + jsonResponse(w, map[string]interface{}{ + "value": ` + + + + + + + + + + + + +`, + }) + return + } + + if strings.Contains(path, "/window/size") { + jsonResponse(w, map[string]interface{}{ + "value": map[string]interface{}{"width": 390.0, "height": 844.0}, + }) + return + } + + jsonResponse(w, map[string]interface{}{"status": 0}) + })) +} + +// TestResolveRelativeSelectorPrefersClosestOverDeepest verifies that directional +// relative selectors (below/above/leftOf/rightOf) pick the closest element by +// distance rather than the deepest in the DOM. This matches Maestro's +// .firstOrNull() behavior on the distance-sorted candidate list. +func TestResolveRelativeSelectorPrefersClosestOverDeepest(t *testing.T) { + server := mockWDAServerForRelativeDepthTest() + defer server.Close() + driver := createTestDriver(server) + + source, _ := driver.client.Source() + elements, _ := ParsePageSource(source) + + sel := flow.Selector{ + Below: &flow.Selector{Text: "Email Address"}, + } + + info, err := driver.resolveRelativeSelector(sel, elements) + if err != nil { + t.Fatalf("Expected success, got: %v", err) + } + if info == nil { + t.Fatal("Expected element info") + } + + // The closest element below "Email Address" (bottom at y=130) is the + // TextField at y=140, not the deeply-nested Link at y=350 (depth 5). + if info.Text != "email input" { + t.Errorf("Expected closest element 'email input', got '%s'", info.Text) + } + if info.Bounds.Y != 140 { + t.Errorf("Expected element at y=140, got y=%d", info.Bounds.Y) + } +} + // TestEraseTextWithActiveElement tests eraseText with active element func TestEraseTextWithActiveElement(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {