mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +00:00
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.
This commit is contained in:
1 parent
a69bac7abd
commit
372fd23d87
3 files changed
+40
-5
No files matched your search
@@ -37,7 +37,7 @@ func TranslateStringSelector(selector string) (string, bool, error) {
|
|||||||
case "tag":
|
case "tag":
|
||||||
return cssEscape(value), false, nil
|
return cssEscape(value), false, nil
|
||||||
case "text":
|
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":
|
case "desc", "label", "content-desc", "accessibilityLabel", "accessibilityText", "ariaLabel", "aria-label":
|
||||||
return `[aria-label="` + cssEscape(value) + `"]`, false, nil
|
return `[aria-label="` + cssEscape(value) + `"]`, false, nil
|
||||||
case "descPrefix":
|
case "descPrefix":
|
||||||
@@ -82,6 +82,28 @@ func cssEscape(value string) string {
|
|||||||
return builder.String()
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func xpathEscape(value string) string {
|
// xpathStringLiteral wraps the value in a valid XPath 1.0 string literal.
|
||||||
return strings.ReplaceAll(value, `"`, `\"`)
|
// 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()
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,9 @@ func TestTranslateStringSelector_KnownKeys(t *testing.T) {
|
|||||||
{"class:btn-primary", `[class~="btn-primary"]`, false},
|
{"class:btn-primary", `[class~="btn-primary"]`, false},
|
||||||
{"tag:button", `button`, false},
|
{"tag:button", `button`, false},
|
||||||
{"text:Sign in", `//*[normalize-space(text())="Sign in"]`, true},
|
{"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},
|
{"desc:logout", `[aria-label="logout"]`, false},
|
||||||
{"label:logout", `[aria-label="logout"]`, false},
|
{"label:logout", `[aria-label="logout"]`, false},
|
||||||
{"accessibilityLabel:logout", `[aria-label="logout"]`, false},
|
{"accessibilityLabel:logout", `[aria-label="logout"]`, false},
|
||||||
|
|||||||
@@ -111,12 +111,22 @@ function selectorFromObject(selector: Record<string, string | boolean | undefine
|
|||||||
}
|
}
|
||||||
if (textValue !== undefined && parts.length === 0) {
|
if (textValue !== undefined && parts.length === 0) {
|
||||||
return {
|
return {
|
||||||
xpath: `//*[normalize-space(text())="${textValue.replace(/"/g, '\\"')}"]`,
|
xpath: `//*[normalize-space(text())=${xpathStringLiteral(textValue)}]`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { css: parts.join("") };
|
return { css: parts.join("") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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().
|
||||||
|
function xpathStringLiteral(value: string): string {
|
||||||
|
if (!value.includes('"')) return `"${value}"`;
|
||||||
|
if (!value.includes("'")) return `'${value}'`;
|
||||||
|
const parts = value.split('"');
|
||||||
|
return `concat(${parts.map((p) => `"${p}"`).join(`, '"', `)})`;
|
||||||
|
}
|
||||||
|
|
||||||
function selectorFromString(selector: string): { css?: string; xpath?: string } {
|
function selectorFromString(selector: string): { css?: string; xpath?: string } {
|
||||||
const colon = selector.indexOf(":");
|
const colon = selector.indexOf(":");
|
||||||
if (colon <= 0) {
|
if (colon <= 0) {
|
||||||
@@ -125,7 +135,7 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
|
|||||||
const kind = selector.slice(0, colon);
|
const kind = selector.slice(0, colon);
|
||||||
const value = selector.slice(colon + 1);
|
const value = selector.slice(colon + 1);
|
||||||
if (kind === "text") {
|
if (kind === "text") {
|
||||||
return { xpath: `//*[normalize-space(text())="${value.replace(/"/g, '\\"')}"]` };
|
return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` };
|
||||||
}
|
}
|
||||||
if (kind === "descPrefix") {
|
if (kind === "descPrefix") {
|
||||||
return { css: `[aria-label^="${cssEscape(value)}"]` };
|
return { css: `[aria-label^="${cssEscape(value)}"]` };
|
||||||
|
|||||||
Reference in new issue
Block a user