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
70 changes: 69 additions & 1 deletion pkg/driver/appium/pagesource.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,27 @@ func parseBounds(s string) core.Bounds {

// FilterBySelector filters elements by selector properties.
func FilterBySelector(elements []*ParsedElement, sel flow.Selector, platform string) []*ParsedElement {
// Literal-text selectors: prefer EXACT matches over contains matches. A
// price field "7000.00" contains "0" and would otherwise beat a switch
// whose text is exactly "0" — the failure seen on TW-ticket flows through
// Sauce/Appium, where the webview exposes price text that DCD's native
// driver never surfaces. When any element matches exactly, return only
// those; otherwise fall back to the contains semantics flows rely on.
if sel.Text != "" && !looksLikeRegex(sel.Text) {
var exact, rest []*ParsedElement
for _, elem := range elements {
if matchesTextExact(elem, sel.Text, platform) {
exact = append(exact, elem)
} else if matchesSelector(elem, sel, platform) {
rest = append(rest, elem)
}
}
if len(exact) > 0 {
return exact
}
return rest
}

var result []*ParsedElement

for _, elem := range elements {
Expand All @@ -317,6 +338,28 @@ func FilterBySelector(elements []*ParsedElement, sel flow.Selector, platform str
return result
}

// matchesTextExact reports whether any of the element's text-bearing fields
// equals the literal pattern (case-sensitive equality).
func matchesTextExact(elem *ParsedElement, pattern string, platform string) bool {
if pattern == "" {
return false
}
if platform == "ios" {
for _, text := range []string{elem.Label, elem.Name, elem.Value, elem.PlaceholderValue} {
if text == pattern {
return true
}
}
return false
}
for _, text := range []string{elem.Text, elem.ContentDesc, elem.HintText} {
if text == pattern {
return true
}
}
return false
}

func matchesSelector(elem *ParsedElement, sel flow.Selector, platform string) bool {
// Text matching
if sel.Text != "" {
Expand Down Expand Up @@ -426,7 +469,32 @@ func matchesText(pattern string, texts ...string) bool {
return false
}

// Literal text - case-insensitive contains
// Literal text — prefer exact matches, then fall back to contains.
//
// Contains-only matching made a plain selector like `text: "0"` resolve to
// the first node whose text merely CONTAINS 0 — e.g. a TradingView order
// ticket's price field "7000.00" — instead of the switch whose text is
// exactly "0". DCD's native driver never sees the price text (webview
// exposure differs), which is why this only broke on Sauce/Appium. Exact
// first costs nothing for flows that already name full labels, and keeps
// the contains fallback for genuine substring usage.
exactMatched := false
for _, text := range texts {
if text != "" && text == pattern {
exactMatched = true
break
}
}
if exactMatched {
for _, text := range texts {
if text == pattern {
return true
}
}
return false
}

// No exact match — case-insensitive contains.
for _, text := range texts {
if containsIgnoreCase(text, pattern) {
return true
Expand Down
37 changes: 34 additions & 3 deletions pkg/driver/appium/pagesource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,12 @@ func TestFilterBySelector_Android(t *testing.T) {
selector flow.Selector
expected int
}{
{"by text exact", flow.Selector{Text: "Hello"}, 3}, // matches Hello, Hello World, Hello button
{"by text contains", flow.Selector{Text: "World"}, 2},
// Literal text is exact-first: "Hello" resolves only to the exact node
// when one exists, falling back to contains otherwise (matchesText keeps
// the contains fallback for genuine substring usage).
{"by text exact", flow.Selector{Text: "Hello"}, 1},
{"by text contains", flow.Selector{Text: "Hello World"}, 1},
{"by text contains fallback", flow.Selector{Text: "zzz"}, 0},
{"by ID", flow.Selector{ID: "id/hello"}, 1},
{"by ID partial", flow.Selector{ID: "id/"}, 4},
{"by enabled true", flow.Selector{Enabled: boolPtr(true)}, 3},
Expand Down Expand Up @@ -214,7 +218,8 @@ func TestFilterBySelector_iOS(t *testing.T) {
selector flow.Selector
expected int
}{
{"by text (label)", flow.Selector{Text: "Submit"}, 3},
// exact-first: only the exact "Submit" label, not "Submit Order".
{"by text (label)", flow.Selector{Text: "Submit"}, 1},
{"by ID (name)", flow.Selector{ID: "submitBtn"}, 1},
{"by ID partial", flow.Selector{ID: "Btn"}, 3},
}
Expand Down Expand Up @@ -683,3 +688,29 @@ func TestGetClickableElement(t *testing.T) {
t.Errorf("Expected nil for nil input, got %v", result)
}
}

// TestFilterBySelector_LiteralTextPrefersExact is the TT-ticket regression: on
// Sauce/Appium the TradingView order ticket exposes the entry price field text
// ("7000.00"), so a plain `text: "0"` used for the take-profit SWITCH must not
// resolve to the price field merely because it contains a 0.
func TestFilterBySelector_LiteralTextPrefersExact(t *testing.T) {
elements := []*ParsedElement{
{Text: "7000.00", ResourceID: "absolute-limit-price-field", Enabled: true},
{Text: "0", ResourceID: "tp-switch", Enabled: true},
{Text: "Limit", ResourceID: "Limit", Enabled: true},
}

got := FilterBySelector(elements, flow.Selector{Text: "0"}, "android")
if len(got) != 1 {
t.Fatalf("exact-first: expected 1 switch, got %d", len(got))
}
if got[0].ResourceID != "tp-switch" {
t.Errorf("expected the exact '0' switch, got %s", got[0].ResourceID)
}

// No exact match -> contains fallback still applies.
got = FilterBySelector(elements, flow.Selector{Text: "70"}, "android")
if len(got) != 1 || got[0].ResourceID != "absolute-limit-price-field" {
t.Errorf("contains fallback expected the price field, got %d elements", len(got))
}
}
Loading