From 372fd23d870306884a0b6397e16e4733b6e461dd Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 3 May 2026 11:00:23 +0700 Subject: [PATCH] fix(selectors): emit valid XPath 1.0 string literals via concat() Both the Go translator and the V8 runtime escaped " by prepending \, which XPath 1.0 doesn't accept (its string literals have no escape syntax). A `text:` value containing a quote produced malformed XPath that chromedp/document.evaluate rejected. Use the standard concat() composition: when the value contains both ' and ", split on " and join with `, '"', ` so each fragment is wrapped in single or double quotes individually. --- internal/driver/chrome/translate.go | 28 +++++++++++++++++++++--- internal/driver/chrome/translate_test.go | 3 +++ pkg/spec/src/web-runtime.ts | 14 ++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/internal/driver/chrome/translate.go b/internal/driver/chrome/translate.go index a1ca3be..7fa6f81 100644 --- a/internal/driver/chrome/translate.go +++ b/internal/driver/chrome/translate.go @@ -37,7 +37,7 @@ func TranslateStringSelector(selector string) (string, bool, error) { case "tag": return cssEscape(value), false, nil case "text": - return `//*[normalize-space(text())="` + xpathEscape(value) + `"]`, true, nil + return `//*[normalize-space(text())=` + xpathStringLiteral(value) + `]`, true, nil case "desc", "label", "content-desc", "accessibilityLabel", "accessibilityText", "ariaLabel", "aria-label": return `[aria-label="` + cssEscape(value) + `"]`, false, nil case "descPrefix": @@ -82,6 +82,28 @@ func cssEscape(value string) string { return builder.String() } -func xpathEscape(value string) string { - return strings.ReplaceAll(value, `"`, `\"`) +// xpathStringLiteral wraps the value in a valid XPath 1.0 string literal. +// XPath 1.0 has no escape syntax, so a value containing both ' and " must be +// composed via concat(). The output already includes the surrounding quotes +// (or concat() call), so callers don't quote it again. +func xpathStringLiteral(value string) string { + if !strings.ContainsRune(value, '"') { + return `"` + value + `"` + } + if !strings.ContainsRune(value, '\'') { + return `'` + value + `'` + } + parts := strings.Split(value, `"`) + var builder strings.Builder + builder.WriteString(`concat(`) + for index, part := range parts { + if index > 0 { + builder.WriteString(`, '"', `) + } + builder.WriteByte('"') + builder.WriteString(part) + builder.WriteByte('"') + } + builder.WriteByte(')') + return builder.String() } diff --git a/internal/driver/chrome/translate_test.go b/internal/driver/chrome/translate_test.go index 797d14d..f89be06 100644 --- a/internal/driver/chrome/translate_test.go +++ b/internal/driver/chrome/translate_test.go @@ -13,6 +13,9 @@ func TestTranslateStringSelector_KnownKeys(t *testing.T) { {"class:btn-primary", `[class~="btn-primary"]`, false}, {"tag:button", `button`, false}, {"text:Sign in", `//*[normalize-space(text())="Sign in"]`, true}, + {`text:Say "hi"`, `//*[normalize-space(text())='Say "hi"']`, true}, + {`text:it's`, `//*[normalize-space(text())="it's"]`, true}, + {`text:it's "fine"`, `//*[normalize-space(text())=concat("it's ", '"', "fine", '"', "")]`, true}, {"desc:logout", `[aria-label="logout"]`, false}, {"label:logout", `[aria-label="logout"]`, false}, {"accessibilityLabel:logout", `[aria-label="logout"]`, false}, diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index 4469819..6ef107e 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -111,12 +111,22 @@ function selectorFromObject(selector: Record `"${p}"`).join(`, '"', `)})`; +} + function selectorFromString(selector: string): { css?: string; xpath?: string } { const colon = selector.indexOf(":"); if (colon <= 0) { @@ -125,7 +135,7 @@ function selectorFromString(selector: string): { css?: string; xpath?: string } const kind = selector.slice(0, colon); const value = selector.slice(colon + 1); if (kind === "text") { - return { xpath: `//*[normalize-space(text())="${value.replace(/"/g, '\\"')}"]` }; + return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` }; } if (kind === "descPrefix") { return { css: `[aria-label^="${cssEscape(value)}"]` };