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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- `runFlow: when` conditions with variable expressions (e.g., `${output.element.id}`) were never expanded, causing conditions to always evaluate as false and silently skip conditional blocks
- iOS real device: `acceptAlertButtonSelector` matched "Don't Allow" instead of "Allow" — `CONTAINS[c] 'Allow'` matched both buttons, causing WDA to reject permission dialogs. Changed to `BEGINSWITH[c] 'Allow'` with `OK` fallback for older iOS versions
- Android: `scroll` and `scrollUntilVisible` did not scroll — Appium `/appium/gestures/scroll` endpoint is unreliable on many devices. Replaced with ADB `input swipe` for direct OS-level input injection (falls back to Appium if ADB is unavailable). Also added on-screen bounds verification to prevent false positives from off-screen elements in the Android view hierarchy

## [1.0.7] - 2026-02-20

Expand Down
106 changes: 91 additions & 15 deletions pkg/driver/uiautomator2/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package uiautomator2

import (
"context"
"errors"
"fmt"
"strconv"
"strings"
Expand Down Expand Up @@ -395,23 +396,37 @@ func (d *Driver) scroll(step *flow.ScrollStep) *core.CommandResult {
direction = "down"
}

// Get screen size for dynamic scroll area
// Get screen size for scroll coordinates
width, height, err := d.screenSize()
if err != nil {
return errorResult(err, "Failed to get screen size")
}

// Use most of screen for scroll area (leave margins)
area := uiautomator2.NewRect(0, height/8, width, height*3/4)

// /appium/gestures/scroll already uses scroll semantics — no inversion needed
if err := d.client.ScrollInArea(area, direction, 0.5, 0); err != nil {
// Use ADB input swipe for reliable scrolling
if err := d.scrollBySwipe(direction, width, height); err != nil {
return errorResult(err, fmt.Sprintf("Failed to scroll: %v", err))
}

return successResult(fmt.Sprintf("Scrolled %s", direction), nil)
}

// isElementNotFoundError returns true if the error indicates the element was simply
// not found (expected during scrolling). Returns false for infrastructure errors
// (connection refused, request failures, etc.) which should be propagated immediately.
func isElementNotFoundError(err error) bool {
if errors.Is(err, context.DeadlineExceeded) {
return true
}
msg := strings.ToLower(err.Error())
notFoundPhrases := []string{"not found", "no elements match", "no such element", "could not be located", "context deadline exceeded"}
for _, phrase := range notFoundPhrases {
if strings.Contains(msg, phrase) {
return true
}
}
return false
}

func (d *Driver) scrollUntilVisible(step *flow.ScrollUntilVisibleStep) *core.CommandResult {
direction := strings.ToLower(step.Direction)
if direction == "" {
Expand All @@ -428,26 +443,30 @@ func (d *Driver) scrollUntilVisible(step *flow.ScrollUntilVisibleStep) *core.Com
}
deadline := time.Now().Add(timeout)

// Get screen size for dynamic scroll area
// Get screen size for scroll coordinates
width, height, err := d.screenSize()
if err != nil {
return errorResult(err, "Failed to get screen size")
}

// Use most of screen for scroll area (leave margins)
area := uiautomator2.NewRect(0, height/8, width, height*3/4)

for i := 0; i < maxScrolls && time.Now().Before(deadline); i++ {
// Try to find element (short timeout - includes page source fallback)
_, info, err := d.findElement(step.Element, true, 1000)
if err == nil && info != nil {
// Element found - return success
return successResult(fmt.Sprintf("Element found after %d scrolls", i), info)
// On Android, UIAutomator can find elements that exist in the view hierarchy
// but are off-screen (e.g., in ScrollView). Verify the element is actually
// visible on screen by checking its bounds overlap with the viewport.
if isElementOnScreen(info, width, height) {
return successResult(fmt.Sprintf("Element found after %d scrolls", i), info)
}
// Element exists in hierarchy but is off-screen - continue scrolling
} else if err != nil && info == nil && !isElementNotFoundError(err) {
return errorResult(err, "Failed to find element")
}

// /appium/gestures/scroll already uses scroll semantics — no inversion needed
if err := d.client.ScrollInArea(area, direction, 0.3, 0); err != nil {
return errorResult(err, fmt.Sprintf("Failed to scroll: %v", err))
// Use ADB input swipe for reliable scrolling (Appium gestures/scroll is unreliable)
if err := d.scrollBySwipe(direction, width, height); err != nil {
return errorResult(err, "Failed to scroll")
}

time.Sleep(300 * time.Millisecond)
Expand All @@ -456,6 +475,63 @@ func (d *Driver) scrollUntilVisible(step *flow.ScrollUntilVisibleStep) *core.Com
return errorResult(fmt.Errorf("element not found"), fmt.Sprintf("Element not found after %d scrolls", maxScrolls))
}

// scrollBySwipe performs a scroll gesture using ADB input swipe for reliability.
// Falls back to Appium gestures/scroll if ADB is not available.
func (d *Driver) scrollBySwipe(direction string, screenWidth, screenHeight int) error {
centerX := screenWidth / 2
startY := screenHeight * 3 / 5
endY := screenHeight * 2 / 5
durationMs := 300

// Calculate swipe coordinates based on direction
// Swipe direction is opposite of scroll direction:
// scroll DOWN (see content below) = swipe finger UP
// scroll UP (see content above) = swipe finger DOWN
var fromX, fromY, toX, toY int
switch direction {
case "up":
fromX, fromY = centerX, endY
toX, toY = centerX, startY
case "down":
fromX, fromY = centerX, startY
toX, toY = centerX, endY
case "left":
centerY := screenHeight / 2
fromX, fromY = screenWidth*2/5, centerY
toX, toY = screenWidth*3/5, centerY
case "right":
centerY := screenHeight / 2
fromX, fromY = screenWidth*3/5, centerY
toX, toY = screenWidth*2/5, centerY
default:
fromX, fromY = centerX, startY
toX, toY = centerX, endY
}

// Prefer ADB shell for reliable input injection
if d.device != nil {
cmd := fmt.Sprintf("input swipe %d %d %d %d %d", fromX, fromY, toX, toY, durationMs)
_, err := d.device.Shell(cmd)
return err
}

// Fallback to Appium gestures if no ADB access — this path is unreliable
// on many Android devices/emulators, so log a warning to aid debugging.
logger.Warn("ADB not available, falling back to Appium scroll (may be unreliable)")
area := uiautomator2.NewRect(0, screenHeight/8, screenWidth, screenHeight*3/4)
return d.client.ScrollInArea(area, direction, 0.5, 0)
}

// isElementOnScreen checks if an element's bounds overlap with the visible screen area.
// Returns false if bounds have no area (zero width or height) or are entirely off-screen.
func isElementOnScreen(info *core.ElementInfo, screenWidth, screenHeight int) bool {
b := info.Bounds
if b.Width == 0 || b.Height == 0 {
return false
}
return b.X+b.Width > 0 && b.X < screenWidth && b.Y+b.Height > 0 && b.Y < screenHeight
}

func (d *Driver) swipe(step *flow.SwipeStep) *core.CommandResult {
// Check if coordinate-based swipe (percentage or absolute)
if step.Start != "" && step.End != "" {
Expand Down
29 changes: 29 additions & 0 deletions pkg/driver/uiautomator2/commands_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package uiautomator2

import (
"context"
"errors"
"fmt"
"net/http"
Expand Down Expand Up @@ -4175,6 +4176,34 @@ func TestScrollUntilVisibleDefaultMaxScrolls(t *testing.T) {
// Verify MockUIA2Client satisfies UIA2Client at compile time.
var _ UIA2Client = (*MockUIA2Client)(nil)

func TestIsElementNotFoundError(t *testing.T) {
tests := []struct {
name string
err error
expected bool
}{
{"context deadline exceeded", context.DeadlineExceeded, true},
{"wrapped deadline exceeded", fmt.Errorf("element 'x' not found: %w", context.DeadlineExceeded), true},
{"element not found", fmt.Errorf("element not found"), true},
{"no elements match", fmt.Errorf("no elements match selector"), true},
{"no such element", fmt.Errorf("no such element: An element could not be located"), true},
{"could not be located", fmt.Errorf("An element could not be located on the page"), true},
{"appium deadline with no such element", fmt.Errorf("context deadline exceeded: no such element: An element could not be located on the page using the given search parameters"), true},
{"connection refused", fmt.Errorf("connection refused"), false},
{"send request failed", fmt.Errorf("send request failed"), false},
{"EOF", fmt.Errorf("unexpected EOF"), false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isElementNotFoundError(tt.err)
if got != tt.expected {
t.Errorf("isElementNotFoundError(%q) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}

// Verify uiautomator2.DeviceInfo is used correctly.
var _ = &uiautomator2.DeviceInfo{}

Expand Down
152 changes: 152 additions & 0 deletions pkg/driver/uiautomator2/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1977,6 +1977,158 @@
}
}

func TestScrollUntilVisibleOffScreenElement(t *testing.T) {
// Element is found by UiAutomator but is off-screen (y=3000 on 2400px screen).
// scrollUntilVisible should scroll via ADB until the element moves on-screen.
findCount := 0
server := setupMockServer(t, map[string]func(w http.ResponseWriter, r *http.Request){
"POST /element": func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"value": map[string]string{"ELEMENT": "elem-offscreen"},
})
},
"GET /element/elem-offscreen/text": func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"value": "Target"})
},
"GET /element/elem-offscreen/rect": func(w http.ResponseWriter, r *http.Request) {
findCount++
// First 2 finds: element is off-screen (below viewport)
// Third find: element has scrolled into view
y := 3000
if findCount >= 3 {
y = 500
}
writeJSON(w, map[string]interface{}{
"value": map[string]int{"x": 100, "y": y, "width": 200, "height": 50},
})
},
"GET /source": func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"value": `<hierarchy><node text="Other" bounds="[0,0][100,100]"/></hierarchy>`,
})
},
})
defer server.Close()

shell := &MockShellExecutor{}
client := newMockHTTPClient(server.URL)
info := &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 2400}
driver := New(client.Client, info, shell)

step := &flow.ScrollUntilVisibleStep{
Element: flow.Selector{ID: "target-button"},
Direction: "UP",
}
result := driver.Execute(step)

if !result.Success {
t.Errorf("expected success, got error: %v", result.Error)
}
// Should have issued at least 2 ADB swipe commands before finding on-screen
swipeCount := 0
for _, cmd := range shell.commands {
if strings.HasPrefix(cmd, "input swipe") {
swipeCount++
// Direction UP = finger moves down = fromY < toY
var fromX, fromY, toX, toY, dur int
fmt.Sscanf(cmd, "input swipe %d %d %d %d %d", &fromX, &fromY, &toX, &toY, &dur)

Check failure on line 2034 in pkg/driver/uiautomator2/driver_test.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `fmt.Sscanf` is not checked (errcheck)
if fromY >= toY {
t.Errorf("scroll UP should produce finger-down swipe (fromY < toY), got fromY=%d toY=%d", fromY, toY)
}
}
}
if swipeCount < 2 {
t.Errorf("expected at least 2 ADB swipes for off-screen element, got %d", swipeCount)
}
}

func TestScrollUntilVisibleUsesADBSwipe(t *testing.T) {
server := setupMockServer(t, map[string]func(w http.ResponseWriter, r *http.Request){
"POST /element": func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"value": map[string]string{"ELEMENT": ""},
})
},
"GET /source": func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"value": `<hierarchy><node text="Other" bounds="[0,0][100,100]"/></hierarchy>`,
})
},
})
defer server.Close()

shell := &MockShellExecutor{}
client := newMockHTTPClient(server.URL)
info := &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 2400}
driver := New(client.Client, info, shell)

step := &flow.ScrollUntilVisibleStep{
Element: flow.Selector{Text: "Target"},
Direction: "down",
MaxScrolls: 1,
}
driver.Execute(step)

if len(shell.commands) == 0 {
t.Fatal("expected ADB shell commands for scroll")
}
cmd := shell.commands[0]
if !strings.HasPrefix(cmd, "input swipe") {
t.Errorf("expected 'input swipe' command, got: %s", cmd)
}
// Direction DOWN = finger moves up = fromY > toY
var fromX, fromY, toX, toY, dur int
fmt.Sscanf(cmd, "input swipe %d %d %d %d %d", &fromX, &fromY, &toX, &toY, &dur)

Check failure on line 2081 in pkg/driver/uiautomator2/driver_test.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `fmt.Sscanf` is not checked (errcheck)
if fromY <= toY {
t.Errorf("scroll DOWN should produce finger-up swipe (fromY > toY), got fromY=%d toY=%d", fromY, toY)
}
}

func TestScrollUntilVisibleConnectionError(t *testing.T) {
// Create a server that we'll shut down mid-scroll to simulate connection failure
callCount := 0
server := setupMockServer(t, map[string]func(w http.ResponseWriter, r *http.Request){
"POST /element": func(w http.ResponseWriter, r *http.Request) {
callCount++
writeJSON(w, map[string]interface{}{
"value": map[string]string{"ELEMENT": ""},
})
},
"GET /source": func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"value": `<hierarchy><node text="Other" bounds="[0,0][100,100]"/></hierarchy>`,
})
},
"GET /appium/device/info": func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{
"value": map[string]interface{}{"realDisplaySize": "1080x2400"},
})
},
})

client := newMockHTTPClient(server.URL)
info := &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 2400}
driver := New(client.Client, info, nil)

// Shut down the server to simulate connection failure
server.Close()

step := &flow.ScrollUntilVisibleStep{
Element: flow.Selector{Text: "Target"},
Direction: "down",
MaxScrolls: 10,
}
result := driver.Execute(step)

if result.Success {
t.Fatal("expected failure on connection error")
}
// Should get a connection error, not "element not found after N scrolls"
if strings.Contains(result.Error.Error(), "not found after") {
t.Errorf("expected connection error to be propagated, got: %s", result.Error.Error())
}
}

// ============================================================================
// Relative Selector Tests (uses HTTP mock for anchor + page source for target)
// ============================================================================
Expand Down
Loading