Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion pkg/driver/appium/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions pkg/driver/appium/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy rotation="0">
<android.widget.FrameLayout bounds="[0,0][1080,2340]">
<android.widget.TextView text="Email Address" bounds="[100,100][500,130]"/>
<android.widget.EditText text="email input" clickable="true" enabled="true" bounds="[100,140][500,180]"/>
<android.widget.FrameLayout bounds="[100,300][500,500]">
<android.widget.FrameLayout bounds="[100,300][500,500]">
<android.widget.FrameLayout bounds="[100,300][500,500]">
<android.widget.TextView text="deep link" clickable="true" enabled="true" bounds="[100,350][500,380]"/>
</android.widget.FrameLayout>
</android.widget.FrameLayout>
</android.widget.FrameLayout>
</android.widget.FrameLayout>
</hierarchy>`,
})
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()
Expand Down
18 changes: 16 additions & 2 deletions pkg/driver/uiautomator2/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions pkg/driver/uiautomator2/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
<node text="Email Address" bounds="[100,100][500,130]" class="android.widget.TextView" />
<node text="email input" bounds="[100,140][500,180]" class="android.widget.EditText" clickable="true" enabled="true" />
<node bounds="[100,300][500,500]" class="android.widget.FrameLayout">
<node bounds="[100,300][500,500]" class="android.widget.FrameLayout">
<node bounds="[100,300][500,500]" class="android.widget.FrameLayout">
<node text="deep link" bounds="[100,350][500,380]" class="android.widget.TextView" clickable="true" enabled="true" />
</node>
</node>
</node>
</hierarchy>`

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 := `<?xml version="1.0" encoding="UTF-8"?>
<hierarchy>
Expand Down
9 changes: 8 additions & 1 deletion pkg/driver/wda/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 74 additions & 0 deletions pkg/driver/wda/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": `<?xml version="1.0" encoding="UTF-8"?>
<AppiumAUT>
<XCUIElementTypeApplication type="XCUIElementTypeApplication" name="TestApp" enabled="true" visible="true" x="0" y="0" width="390" height="844">
<XCUIElementTypeStaticText type="XCUIElementTypeStaticText" label="Email Address" enabled="true" visible="true" x="50" y="100" width="290" height="30"/>
<XCUIElementTypeTextField type="XCUIElementTypeTextField" label="email input" enabled="true" visible="true" x="50" y="140" width="290" height="40"/>
<XCUIElementTypeOther type="XCUIElementTypeOther" enabled="true" visible="true" x="50" y="300" width="290" height="100">
<XCUIElementTypeOther type="XCUIElementTypeOther" enabled="true" visible="true" x="50" y="300" width="290" height="100">
<XCUIElementTypeOther type="XCUIElementTypeOther" enabled="true" visible="true" x="50" y="300" width="290" height="100">
<XCUIElementTypeLink type="XCUIElementTypeLink" label="deep link" enabled="true" visible="true" x="50" y="350" width="290" height="30"/>
</XCUIElementTypeOther>
</XCUIElementTypeOther>
</XCUIElementTypeOther>
</XCUIElementTypeApplication>
</AppiumAUT>`,
})
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) {
Expand Down