diff --git a/internal/driver/chrome/translate.go b/internal/driver/chrome/translate.go index 74299ce..7b0295b 100644 --- a/internal/driver/chrome/translate.go +++ b/internal/driver/chrome/translate.go @@ -2,7 +2,9 @@ package chrome import ( "errors" + "fmt" "strings" + "unicode/utf8" ) // TranslateStringSelector converts a legacy string selector ("id:foo", @@ -25,7 +27,7 @@ func TranslateStringSelector(selector string) (string, bool, error) { case "id", "resource-id": return `[id="` + cssEscape(value) + `"]`, false, nil case "class": - return "." + cssEscape(value), false, nil + return `[class~="` + cssEscape(value) + `"]`, false, nil case "tag": return cssEscape(value), false, nil case "text": @@ -43,20 +45,29 @@ func TranslateStringSelector(selector string) (string, bool, error) { } } -// 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. +// cssEscape escapes a value for use inside a CSS double-quoted string +// (`[attr="VALUE"]`). Per the CSSOM spec for serializing strings: +// - U+0000 becomes U+FFFD (REPLACEMENT CHARACTER) +// - control characters (U+0001-U+001F, U+007F) become \HEX escapes +// - " and \ are escaped with a leading backslash +// - everything else passes through, including non-ASCII +// +// Callers should not pass this output into identifier contexts (class names, +// tag names) — use an attribute selector form (`[class~="..."]`) instead. 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 '\\', '"': + for _, r := range value { + switch { + case r == 0: + builder.WriteRune(utf8.RuneError) + case (r >= 0x01 && r <= 0x1F) || r == 0x7F: + fmt.Fprintf(&builder, "\\%X ", r) + case r == '\\' || r == '"': builder.WriteByte('\\') - builder.WriteByte(c) + builder.WriteRune(r) default: - builder.WriteByte(c) + builder.WriteRune(r) } } return builder.String() diff --git a/internal/driver/chrome/translate_test.go b/internal/driver/chrome/translate_test.go index 0329948..ad05d10 100644 --- a/internal/driver/chrome/translate_test.go +++ b/internal/driver/chrome/translate_test.go @@ -10,7 +10,7 @@ func TestTranslateStringSelector_KnownKeys(t *testing.T) { }{ {"id:email", `[id="email"]`, false}, {"resource-id:account-name", `[id="account-name"]`, false}, - {"class:btn-primary", `.btn-primary`, false}, + {"class:btn-primary", `[class~="btn-primary"]`, false}, {"tag:button", `button`, false}, {"text:Sign in", `//*[normalize-space(text())="Sign in"]`, true}, {"desc:logout", `[aria-label="logout"]`, false}, @@ -63,3 +63,28 @@ func TestTranslateStringSelector_RejectsMissingPrefix(t *testing.T) { t.Error("expected error for empty selector") } } + +func TestCSSEscape_ControlCharactersAndNUL(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"plain ascii", "hello", "hello"}, + {"double quote", `say "hi"`, `say \"hi\"`}, + {"backslash", `a\b`, `a\\b`}, + {"newline", "a\nb", `a\A b`}, + {"carriage return", "a\rb", `a\D b`}, + {"form feed", "a\fb", `a\C b`}, + {"NUL replaced", "a\x00b", "a�b"}, + {"DEL", "a\x7Fb", `a\7F b`}, + {"non-ascii passes through", "café", "café"}, + } + for _, testCase := range cases { + got := cssEscape(testCase.input) + if got != testCase.want { + t.Errorf("%s: cssEscape(%q) = %q, want %q", + testCase.name, testCase.input, got, testCase.want) + } + } +}