mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 20:17:09 +00:00
feat(chrome): translate legacy string selectors to CSS/XPath
TapSelector now maps id:/desc:/descPrefix:/testTag:/etc. through TranslateStringSelector. Unknown prefixes pass through to a CSS attribute selector so a future Maestro key works without a release.
This commit is contained in:
1 parent
b213f2470b
commit
fa0c82fb40
3 files changed
+142
-3
No files matched your search
@@ -126,9 +126,16 @@ func (d *Driver) Tap(_ context.Context, x, y int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
func (d *Driver) TapSelector(_ context.Context, selector string) error {
|
||||||
return chromedp.Run(d.tabCtx,
|
target, isXPath, err := TranslateStringSelector(selector)
|
||||||
chromedp.Click(selector, chromedp.NodeVisible),
|
if err != nil {
|
||||||
)
|
// Fall back to passing the string straight through; chromedp will
|
||||||
|
// reject it loudly if it isn't a valid CSS selector.
|
||||||
|
target = selector
|
||||||
|
}
|
||||||
|
if isXPath {
|
||||||
|
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch))
|
||||||
|
}
|
||||||
|
return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Driver) InputText(_ context.Context, text string) error {
|
func (d *Driver) InputText(_ context.Context, text string) error {
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package chrome
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TranslateStringSelector converts a legacy string selector ("id:foo",
|
||||||
|
// "descPrefix:bar") into a CSS selector or XPath expression usable from the
|
||||||
|
// chrome driver's TapSelector fallback path. The boolean return is true when
|
||||||
|
// the result is XPath rather than CSS. Unknown prefixes pass through to a CSS
|
||||||
|
// attribute match by the same name so a Maestro-side addition (e.g. a future
|
||||||
|
// "role:") works without a Sanderling release.
|
||||||
|
func TranslateStringSelector(selector string) (string, bool, error) {
|
||||||
|
if selector == "" {
|
||||||
|
return "", false, errors.New("empty selector")
|
||||||
|
}
|
||||||
|
colon := strings.IndexByte(selector, ':')
|
||||||
|
if colon <= 0 {
|
||||||
|
return "", false, errors.New("selector missing prefix (expected `kind:value`)")
|
||||||
|
}
|
||||||
|
kind := selector[:colon]
|
||||||
|
value := selector[colon+1:]
|
||||||
|
switch kind {
|
||||||
|
case "id", "resource-id":
|
||||||
|
return `[id="` + cssEscape(value) + `"]`, false, nil
|
||||||
|
case "class":
|
||||||
|
return "." + cssEscape(value), false, nil
|
||||||
|
case "tag":
|
||||||
|
return cssEscape(value), false, nil
|
||||||
|
case "text":
|
||||||
|
return `//*[normalize-space(text())="` + xpathEscape(value) + `"]`, true, nil
|
||||||
|
case "desc", "label", "content-desc", "accessibilityLabel", "accessibilityText", "ariaLabel", "aria-label":
|
||||||
|
return `[aria-label="` + cssEscape(value) + `"]`, false, nil
|
||||||
|
case "descPrefix":
|
||||||
|
return `[aria-label^="` + cssEscape(value) + `"]`, false, nil
|
||||||
|
case "testTag", "testID", "testid", "data-testid":
|
||||||
|
return `[data-testid="` + cssEscape(value) + `"]`, false, nil
|
||||||
|
case "placeholder", "placeholderValue", "hintText":
|
||||||
|
return `[placeholder="` + cssEscape(value) + `"]`, false, nil
|
||||||
|
default:
|
||||||
|
return `[` + kind + `="` + cssEscape(value) + `"]`, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cssEscape escapes the subset of characters that break a CSS string literal
|
||||||
|
// inside `[attr="..."]`. Quotes and backslashes are escaped per CSSOM's
|
||||||
|
// CSS.escape rules; other printable bytes pass through.
|
||||||
|
func cssEscape(value string) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.Grow(len(value))
|
||||||
|
for index := 0; index < len(value); index++ {
|
||||||
|
c := value[index]
|
||||||
|
switch c {
|
||||||
|
case '\\', '"':
|
||||||
|
builder.WriteByte('\\')
|
||||||
|
builder.WriteByte(c)
|
||||||
|
default:
|
||||||
|
builder.WriteByte(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func xpathEscape(value string) string {
|
||||||
|
return strings.ReplaceAll(value, `"`, `\"`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package chrome
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestTranslateStringSelector_KnownKeys(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
selector string
|
||||||
|
want string
|
||||||
|
isXPath bool
|
||||||
|
}{
|
||||||
|
{"id:email", `[id="email"]`, false},
|
||||||
|
{"resource-id:account-name", `[id="account-name"]`, false},
|
||||||
|
{"class:btn-primary", `.btn-primary`, false},
|
||||||
|
{"tag:button", `button`, false},
|
||||||
|
{"text:Sign in", `//*[normalize-space(text())="Sign in"]`, true},
|
||||||
|
{"desc:logout", `[aria-label="logout"]`, false},
|
||||||
|
{"label:logout", `[aria-label="logout"]`, false},
|
||||||
|
{"accessibilityLabel:logout", `[aria-label="logout"]`, false},
|
||||||
|
{"aria-label:Sign in", `[aria-label="Sign in"]`, false},
|
||||||
|
{"descPrefix:account:", `[aria-label^="account:"]`, false},
|
||||||
|
{"testTag:submit", `[data-testid="submit"]`, false},
|
||||||
|
{"testID:submit", `[data-testid="submit"]`, false},
|
||||||
|
{"placeholder:Email", `[placeholder="Email"]`, false},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
got, isXPath, err := TranslateStringSelector(testCase.selector)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%q: unexpected error %v", testCase.selector, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != testCase.want || isXPath != testCase.isXPath {
|
||||||
|
t.Errorf("%q: got (%q, xpath=%v), want (%q, xpath=%v)",
|
||||||
|
testCase.selector, got, isXPath, testCase.want, testCase.isXPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslateStringSelector_UnknownPrefixPassesThrough(t *testing.T) {
|
||||||
|
got, _, err := TranslateStringSelector("role:button")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != `[role="button"]` {
|
||||||
|
t.Errorf("unknown prefix should map to attribute selector, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslateStringSelector_EscapesQuotes(t *testing.T) {
|
||||||
|
got, _, err := TranslateStringSelector(`label:say "hi"`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != `[aria-label="say \"hi\""]` {
|
||||||
|
t.Errorf("expected escaped quotes, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranslateStringSelector_RejectsMissingPrefix(t *testing.T) {
|
||||||
|
if _, _, err := TranslateStringSelector("foo"); err == nil {
|
||||||
|
t.Error("expected error for missing prefix")
|
||||||
|
}
|
||||||
|
if _, _, err := TranslateStringSelector(""); err == nil {
|
||||||
|
t.Error("expected error for empty selector")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user