From c35dacb967b65802e85e078cfe334bbea1e32a97 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Mon, 16 Feb 2026 17:11:58 -0300 Subject: [PATCH 1/5] Fix variable expansion in runFlow when conditions CheckCondition() was passing when condition selectors (visible/notVisible) directly to the driver without expanding variables. Expressions like ${output.homeScreen.buttons.profile} were sent as literal strings, causing conditions to always evaluate as false and silently skip conditional blocks. Three changes: - CheckCondition(): expand variables in visible/notVisible selectors and platform field before evaluating against the driver - ExpandStep(): add RunFlowStep case to expand variables in File, When condition fields, and Env values - executeNestedStep(): call ExpandStep before executeRunFlow for nested RunFlowStep execution --- pkg/executor/flow_runner.go | 1 + pkg/executor/scripting.go | 28 +++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/pkg/executor/flow_runner.go b/pkg/executor/flow_runner.go index ccd1ec0..afa7352 100644 --- a/pkg/executor/flow_runner.go +++ b/pkg/executor/flow_runner.go @@ -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) diff --git a/pkg/executor/scripting.go b/pkg/executor/scripting.go index 5b684bd..1fbcf3c 100644 --- a/pkg/executor/scripting.go +++ b/pkg/executor/scripting.go @@ -421,8 +421,9 @@ func (se *ScriptEngine) ExecuteAssertCondition(ctx context.Context, step *flow.A func (se *ScriptEngine) CheckCondition(ctx context.Context, cond flow.Condition, driver core.Driver) bool { // Check platform (first — no device call needed) if cond.Platform != "" { + expandedPlatform := se.ExpandVariables(cond.Platform) if info := driver.GetPlatformInfo(); info != nil { - if !strings.EqualFold(cond.Platform, info.Platform) { + if !strings.EqualFold(expandedPlatform, info.Platform) { return false } } @@ -430,7 +431,8 @@ func (se *ScriptEngine) CheckCondition(ctx context.Context, cond flow.Condition, // Check visible if cond.Visible != nil { - visibleStep := &flow.AssertVisibleStep{Selector: *cond.Visible} + expandedSelector := se.expandSelector(cond.Visible) + visibleStep := &flow.AssertVisibleStep{Selector: *expandedSelector} result := driver.Execute(visibleStep) if !result.Success { return false @@ -439,7 +441,8 @@ func (se *ScriptEngine) CheckCondition(ctx context.Context, cond flow.Condition, // Check notVisible if cond.NotVisible != nil { - notVisibleStep := &flow.AssertNotVisibleStep{Selector: *cond.NotVisible} + expandedSelector := se.expandSelector(cond.NotVisible) + notVisibleStep := &flow.AssertNotVisibleStep{Selector: *expandedSelector} result := driver.Execute(notVisibleStep) if !result.Success { return false @@ -530,6 +533,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) + } } } From 19b471ae0c9097a2915a7e6bafab3f6501e9a2bb Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Mon, 16 Feb 2026 17:13:22 -0300 Subject: [PATCH 2/5] Add tests for when condition variable expansion - TestExpandStep_RunFlowStep: verifies ExpandStep expands variables in File, When.Visible, When.NotVisible, When.Script, When.Platform, and Env map values - TestExpandStep_RunFlowStep_NilWhen: verifies nil When doesn't panic - TestCheckCondition_ExpandsVisibleSelectorVariables: verifies expanded selector ID reaches the driver - TestCheckCondition_ExpandsNotVisibleSelectorVariables: same for NotVisible text selector - TestCheckCondition_ExpandsPlatformVariable: verifies platform string expansion before comparison --- pkg/executor/scripting_test.go | 173 +++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/pkg/executor/scripting_test.go b/pkg/executor/scripting_test.go index 9089898..26460ae 100644 --- a/pkg/executor/scripting_test.go +++ b/pkg/executor/scripting_test.go @@ -1804,3 +1804,176 @@ 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") + } +} + +// =========================================== +// CheckCondition: variable expansion +// =========================================== + +func TestCheckCondition_ExpandsVisibleSelectorVariables(t *testing.T) { + se := NewScriptEngine() + defer se.Close() + + se.SetVariable("BUTTON_ID", "profile_button") + + // Mock driver always returns success for Execute() + driver := &mockConditionDriver{executeResult: true} + + cond := flow.Condition{ + Visible: &flow.Selector{ID: "${BUTTON_ID}"}, + } + + result := se.CheckCondition(context.Background(), cond, driver) + if !result { + t.Error("CheckCondition should return true when visible element is found") + } + + // Verify the selector was expanded before being sent to the driver + if driver.lastSelector == nil { + t.Fatal("Driver.Execute was not called") + } + if driver.lastSelector.ID != "profile_button" { + t.Errorf("Selector.ID sent to driver = %q, want %q", driver.lastSelector.ID, "profile_button") + } +} + +func TestCheckCondition_ExpandsNotVisibleSelectorVariables(t *testing.T) { + se := NewScriptEngine() + defer se.Close() + + se.SetVariable("LABEL", "Loading") + + driver := &mockConditionDriver{executeResult: true} + + cond := flow.Condition{ + NotVisible: &flow.Selector{Text: "${LABEL}"}, + } + + result := se.CheckCondition(context.Background(), cond, driver) + if !result { + t.Error("CheckCondition should return true when not-visible check passes") + } + + if driver.lastSelector == nil { + t.Fatal("Driver.Execute was not called") + } + if driver.lastSelector.Text != "Loading" { + t.Errorf("Selector.Text sent to driver = %q, want %q", driver.lastSelector.Text, "Loading") + } +} + +func TestCheckCondition_ExpandsPlatformVariable(t *testing.T) { + se := NewScriptEngine() + defer se.Close() + + se.SetVariable("TARGET_PLATFORM", "android") + + driver := &mockConditionDriver{ + executeResult: true, + platform: "android", + } + + cond := flow.Condition{ + Platform: "${TARGET_PLATFORM}", + } + + result := se.CheckCondition(context.Background(), cond, driver) + if !result { + t.Error("CheckCondition should return true when platform matches after expansion") + } +} + +// mockConditionDriver captures the selector passed to Execute for verification. +type mockConditionDriver struct { + executeResult bool + platform string + lastSelector *flow.Selector +} + +func (d *mockConditionDriver) Execute(step flow.Step) *core.CommandResult { + // Capture the selector from assert steps + switch s := step.(type) { + case *flow.AssertVisibleStep: + d.lastSelector = &s.Selector + case *flow.AssertNotVisibleStep: + d.lastSelector = &s.Selector + } + return &core.CommandResult{Success: d.executeResult} +} + +func (d *mockConditionDriver) Screenshot() ([]byte, error) { return nil, nil } +func (d *mockConditionDriver) Hierarchy() ([]byte, error) { return nil, nil } +func (d *mockConditionDriver) GetState() *core.StateSnapshot { return nil } +func (d *mockConditionDriver) GetPlatformInfo() *core.PlatformInfo { + if d.platform == "" { + return &core.PlatformInfo{Platform: "mock"} + } + return &core.PlatformInfo{Platform: d.platform} +} +func (d *mockConditionDriver) SetFindTimeout(ms int) {} +func (d *mockConditionDriver) SetWaitForIdleTimeout(ms int) error { return nil } From fc91b14ccb0d55263d55e079622ace459d2a5561 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Mon, 16 Feb 2026 17:13:31 -0300 Subject: [PATCH 3/5] Update CHANGELOG with runFlow when condition fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3523394..ee6d099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 20549b2c2b2fb4133908040a465552c32f881ab1 Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Wed, 18 Feb 2026 14:15:24 -0300 Subject: [PATCH 4/5] Remove redundant variable expansion from CheckCondition Address PR review feedback: CheckCondition() should only evaluate conditions, not expand variables. ExpandStep() already handles all variable expansion before CheckCondition() is called, so the expansion in CheckCondition() was a redundant second pass. - Revert CheckCondition() to use condition fields directly - Remove CheckCondition expansion tests and mockConditionDriver - Keep ExpandStep RunFlowStep case (the actual fix) --- pkg/core/driver_test.go | 4 +- pkg/executor/scripting.go | 9 +-- pkg/executor/scripting_test.go | 107 --------------------------------- 3 files changed, 5 insertions(+), 115 deletions(-) diff --git a/pkg/core/driver_test.go b/pkg/core/driver_test.go index 9f852c5..9bb59e2 100644 --- a/pkg/core/driver_test.go +++ b/pkg/core/driver_test.go @@ -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}, } diff --git a/pkg/executor/scripting.go b/pkg/executor/scripting.go index 1fbcf3c..c6407cb 100644 --- a/pkg/executor/scripting.go +++ b/pkg/executor/scripting.go @@ -421,9 +421,8 @@ func (se *ScriptEngine) ExecuteAssertCondition(ctx context.Context, step *flow.A func (se *ScriptEngine) CheckCondition(ctx context.Context, cond flow.Condition, driver core.Driver) bool { // Check platform (first — no device call needed) if cond.Platform != "" { - expandedPlatform := se.ExpandVariables(cond.Platform) if info := driver.GetPlatformInfo(); info != nil { - if !strings.EqualFold(expandedPlatform, info.Platform) { + if !strings.EqualFold(cond.Platform, info.Platform) { return false } } @@ -431,8 +430,7 @@ func (se *ScriptEngine) CheckCondition(ctx context.Context, cond flow.Condition, // Check visible if cond.Visible != nil { - expandedSelector := se.expandSelector(cond.Visible) - visibleStep := &flow.AssertVisibleStep{Selector: *expandedSelector} + visibleStep := &flow.AssertVisibleStep{Selector: *cond.Visible} result := driver.Execute(visibleStep) if !result.Success { return false @@ -441,8 +439,7 @@ func (se *ScriptEngine) CheckCondition(ctx context.Context, cond flow.Condition, // Check notVisible if cond.NotVisible != nil { - expandedSelector := se.expandSelector(cond.NotVisible) - notVisibleStep := &flow.AssertNotVisibleStep{Selector: *expandedSelector} + notVisibleStep := &flow.AssertNotVisibleStep{Selector: *cond.NotVisible} result := driver.Execute(notVisibleStep) if !result.Success { return false diff --git a/pkg/executor/scripting_test.go b/pkg/executor/scripting_test.go index 26460ae..1b5d0a3 100644 --- a/pkg/executor/scripting_test.go +++ b/pkg/executor/scripting_test.go @@ -1870,110 +1870,3 @@ func TestScriptEngine_ExpandStep_RunFlowStep_NilWhen(t *testing.T) { t.Errorf("File = %q, want %q", step.File, "test.yaml") } } - -// =========================================== -// CheckCondition: variable expansion -// =========================================== - -func TestCheckCondition_ExpandsVisibleSelectorVariables(t *testing.T) { - se := NewScriptEngine() - defer se.Close() - - se.SetVariable("BUTTON_ID", "profile_button") - - // Mock driver always returns success for Execute() - driver := &mockConditionDriver{executeResult: true} - - cond := flow.Condition{ - Visible: &flow.Selector{ID: "${BUTTON_ID}"}, - } - - result := se.CheckCondition(context.Background(), cond, driver) - if !result { - t.Error("CheckCondition should return true when visible element is found") - } - - // Verify the selector was expanded before being sent to the driver - if driver.lastSelector == nil { - t.Fatal("Driver.Execute was not called") - } - if driver.lastSelector.ID != "profile_button" { - t.Errorf("Selector.ID sent to driver = %q, want %q", driver.lastSelector.ID, "profile_button") - } -} - -func TestCheckCondition_ExpandsNotVisibleSelectorVariables(t *testing.T) { - se := NewScriptEngine() - defer se.Close() - - se.SetVariable("LABEL", "Loading") - - driver := &mockConditionDriver{executeResult: true} - - cond := flow.Condition{ - NotVisible: &flow.Selector{Text: "${LABEL}"}, - } - - result := se.CheckCondition(context.Background(), cond, driver) - if !result { - t.Error("CheckCondition should return true when not-visible check passes") - } - - if driver.lastSelector == nil { - t.Fatal("Driver.Execute was not called") - } - if driver.lastSelector.Text != "Loading" { - t.Errorf("Selector.Text sent to driver = %q, want %q", driver.lastSelector.Text, "Loading") - } -} - -func TestCheckCondition_ExpandsPlatformVariable(t *testing.T) { - se := NewScriptEngine() - defer se.Close() - - se.SetVariable("TARGET_PLATFORM", "android") - - driver := &mockConditionDriver{ - executeResult: true, - platform: "android", - } - - cond := flow.Condition{ - Platform: "${TARGET_PLATFORM}", - } - - result := se.CheckCondition(context.Background(), cond, driver) - if !result { - t.Error("CheckCondition should return true when platform matches after expansion") - } -} - -// mockConditionDriver captures the selector passed to Execute for verification. -type mockConditionDriver struct { - executeResult bool - platform string - lastSelector *flow.Selector -} - -func (d *mockConditionDriver) Execute(step flow.Step) *core.CommandResult { - // Capture the selector from assert steps - switch s := step.(type) { - case *flow.AssertVisibleStep: - d.lastSelector = &s.Selector - case *flow.AssertNotVisibleStep: - d.lastSelector = &s.Selector - } - return &core.CommandResult{Success: d.executeResult} -} - -func (d *mockConditionDriver) Screenshot() ([]byte, error) { return nil, nil } -func (d *mockConditionDriver) Hierarchy() ([]byte, error) { return nil, nil } -func (d *mockConditionDriver) GetState() *core.StateSnapshot { return nil } -func (d *mockConditionDriver) GetPlatformInfo() *core.PlatformInfo { - if d.platform == "" { - return &core.PlatformInfo{Platform: "mock"} - } - return &core.PlatformInfo{Platform: d.platform} -} -func (d *mockConditionDriver) SetFindTimeout(ms int) {} -func (d *mockConditionDriver) SetWaitForIdleTimeout(ms int) error { return nil } From d40f3acf1c6ff9283291083a193c06cc8505c87b Mon Sep 17 00:00:00 2001 From: Gabriel Almeida Date: Thu, 19 Feb 2026 17:41:16 -0300 Subject: [PATCH 5/5] 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) {