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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### 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

## [1.0.7] - 2026-02-20

### Added
Expand Down
4 changes: 2 additions & 2 deletions pkg/core/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ func TestHasNonASCII(t *testing.T) {
{"Hello World 123!", false},
{"", false},
{"abc\t\n", false},
{"\x7f", false}, // DEL is ASCII (127)
{"\x80", true}, // first non-ASCII byte
{"\x7f", false}, // DEL is ASCII (127)
{"\x80", true}, // first non-ASCII byte
{"cafe\u0301", true}, // e with combining accent
{"hello world", false},
}
Expand Down
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
1 change: 1 addition & 0 deletions pkg/executor/flow_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,7 @@ func (fr *FlowRunner) executeNestedStep(step flow.Step) *core.CommandResult {
case *flow.RetryStep:
result = fr.executeRetry(s)
case *flow.RunFlowStep:
fr.script.ExpandStep(step)
result = fr.executeRunFlow(s)
case *flow.TakeScreenshotStep:
fr.script.ExpandStep(step)
Expand Down
19 changes: 19 additions & 0 deletions pkg/executor/scripting.go
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,25 @@ func (se *ScriptEngine) ExpandStep(step flow.Step) {
s.Link = se.ExpandVariables(s.Link)
case *flow.PressKeyStep:
s.Key = se.ExpandVariables(s.Key)
case *flow.RunFlowStep:
s.File = se.ExpandVariables(s.File)
if s.When != nil {
if s.When.Visible != nil {
s.When.Visible = se.expandSelector(s.When.Visible)
}
if s.When.NotVisible != nil {
s.When.NotVisible = se.expandSelector(s.When.NotVisible)
}
if s.When.Script != "" {
s.When.Script = se.ExpandVariables(s.When.Script)
}
if s.When.Platform != "" {
s.When.Platform = se.ExpandVariables(s.When.Platform)
}
}
for k, v := range s.Env {
s.Env[k] = se.ExpandVariables(v)
}
}
}

Expand Down
66 changes: 66 additions & 0 deletions pkg/executor/scripting_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1804,3 +1804,69 @@ func TestScriptEngine_EvalCondition_UndefinedVariable(t *testing.T) {
t.Error("EvalCondition(SOME_UNDEFINED_VAR) should return false for undefined variable")
}
}

// ===========================================
// ExpandStep: RunFlowStep
// ===========================================

func TestScriptEngine_ExpandStep_RunFlowStep(t *testing.T) {
se := NewScriptEngine()
defer se.Close()

se.SetVariable("FLOW_FILE", "auth.yaml")
se.SetVariable("BUTTON_ID", "profile_button")
se.SetVariable("LABEL_TEXT", "Welcome")
se.SetVariable("ENV_VAL", "production")

step := &flow.RunFlowStep{
File: "${FLOW_FILE}",
When: &flow.Condition{
Visible: &flow.Selector{ID: "${BUTTON_ID}"},
NotVisible: &flow.Selector{Text: "${LABEL_TEXT}"},
Script: "${BUTTON_ID} !== undefined",
Platform: "${ENV_VAL}",
},
Env: map[string]string{
"MODE": "${ENV_VAL}",
},
}

se.ExpandStep(step)

if step.File != "auth.yaml" {
t.Errorf("File = %q, want %q", step.File, "auth.yaml")
}
if step.When.Visible.ID != "profile_button" {
t.Errorf("When.Visible.ID = %q, want %q", step.When.Visible.ID, "profile_button")
}
if step.When.NotVisible.Text != "Welcome" {
t.Errorf("When.NotVisible.Text = %q, want %q", step.When.NotVisible.Text, "Welcome")
}
if step.When.Script != "profile_button !== undefined" {
t.Errorf("When.Script = %q, want %q", step.When.Script, "profile_button !== undefined")
}
if step.When.Platform != "production" {
t.Errorf("When.Platform = %q, want %q", step.When.Platform, "production")
}
if step.Env["MODE"] != "production" {
t.Errorf("Env[MODE] = %q, want %q", step.Env["MODE"], "production")
}
}

func TestScriptEngine_ExpandStep_RunFlowStep_NilWhen(t *testing.T) {
se := NewScriptEngine()
defer se.Close()

se.SetVariable("FILE", "test.yaml")

// RunFlowStep with no When condition should not panic
step := &flow.RunFlowStep{
File: "${FILE}",
}

se.ExpandStep(step)

if step.File != "test.yaml" {
t.Errorf("File = %q, want %q", step.File, "test.yaml")
}
}