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) {