diff --git a/pkg/driver/appium/pagesource.go b/pkg/driver/appium/pagesource.go index 8fa3c1ee..0a5ace9c 100644 --- a/pkg/driver/appium/pagesource.go +++ b/pkg/driver/appium/pagesource.go @@ -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 { @@ -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 != "" { @@ -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 diff --git a/pkg/driver/appium/pagesource_test.go b/pkg/driver/appium/pagesource_test.go index c6f0b9a3..3df5539d 100644 --- a/pkg/driver/appium/pagesource_test.go +++ b/pkg/driver/appium/pagesource_test.go @@ -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}, @@ -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}, } @@ -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)) + } +}