From fa0c82fb40963b36c268a9ee9b5e6960904b9f7d Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 27 Apr 2026 18:39:19 +0700 Subject: [PATCH] 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. --- internal/driver/chrome/driver.go | 13 +++-- internal/driver/chrome/translate.go | 67 ++++++++++++++++++++++++ internal/driver/chrome/translate_test.go | 65 +++++++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 internal/driver/chrome/translate.go create mode 100644 internal/driver/chrome/translate_test.go diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 5a76ccd..0592b05 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -126,9 +126,16 @@ func (d *Driver) Tap(_ context.Context, x, y int) error { } func (d *Driver) TapSelector(_ context.Context, selector string) error { - return chromedp.Run(d.tabCtx, - chromedp.Click(selector, chromedp.NodeVisible), - ) + target, isXPath, err := TranslateStringSelector(selector) + 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 { diff --git a/internal/driver/chrome/translate.go b/internal/driver/chrome/translate.go new file mode 100644 index 0000000..74299ce --- /dev/null +++ b/internal/driver/chrome/translate.go @@ -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, `"`, `\"`) +} diff --git a/internal/driver/chrome/translate_test.go b/internal/driver/chrome/translate_test.go new file mode 100644 index 0000000..0329948 --- /dev/null +++ b/internal/driver/chrome/translate_test.go @@ -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") + } +}