mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(selectors): resolve text to the innermost match and scan the root in both forms
an element's text is its whole subtree's text on web and on ios, so every ancestor of a matching element matched too, up to the root. a match a descendant also makes is now dropped, in internal/hierarchy, in the chrome xpath translation and in the page-side web runtime, so all three resolvers name the same element. a raw attribute now matches on a substring (exact for true/false) the way the docs describe, and tree-level FindBySelector considers the root, so ax.find("id:page") and ax.find({id: "page"}) agree.
This commit is contained in:
1 parent
a245194120
commit
ca667b1b77
9 files changed
+526
-49
No files matched your search
@@ -213,10 +213,15 @@ function unknownSelectorKeyMessage(keys: string[]): string {
|
||||
);
|
||||
}
|
||||
|
||||
// A key naming no rule is a raw attribute name, and a raw attribute matches on a
|
||||
// substring, a boolean value exactly (docs/manual/spec-language.md), which is
|
||||
// what internal/hierarchy does with the same key. Matching exactly here made
|
||||
// `data-state:sent` name the badge on Android and nothing at all on web.
|
||||
function cssPart(key: string, value: string): string {
|
||||
const builder = KNOWN_KEY_TO_CSS[key];
|
||||
if (builder) return builder(value);
|
||||
return `[${key}="${cssEscape(value)}"]`;
|
||||
const operator = value === "true" || value === "false" ? "=" : "*=";
|
||||
return `[${key}${operator}"${cssEscape(value)}"]`;
|
||||
}
|
||||
|
||||
// A selector key that can never match yields an empty result, which reads
|
||||
@@ -248,13 +253,21 @@ function selectorFromObject(selector: Record<string, string | boolean | undefine
|
||||
throw new Error(unknownSelectorKeyMessage(unknown));
|
||||
}
|
||||
if (textValue !== undefined && parts.length === 0) {
|
||||
return {
|
||||
xpath: `//*[normalize-space(text())=${xpathStringLiteral(textValue)}]`,
|
||||
};
|
||||
return { xpath: innermostTextXPath(textValue) };
|
||||
}
|
||||
return { css: parts.join("") };
|
||||
}
|
||||
|
||||
// innermostTextXPath matches an element whose text contains value and whose
|
||||
// descendants do not. An element's XPath string value is its whole subtree's
|
||||
// text, so without the not() clause a badge's ancestors up to <html> answer for
|
||||
// it and find lands on the document. internal/hierarchy suppresses the same
|
||||
// matches, and internal/driver/chrome/translate.go builds the same predicate.
|
||||
function innermostTextXPath(value: string): string {
|
||||
const contains = `contains(normalize-space(.), ${xpathStringLiteral(value)})`;
|
||||
return `.//*[${contains} and not(.//*[${contains}])]`;
|
||||
}
|
||||
|
||||
// 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().
|
||||
@@ -272,8 +285,14 @@ function selectorFromString(selector: string): { css?: string; xpath?: string }
|
||||
}
|
||||
const kind = selector.slice(0, colon);
|
||||
const value = selector.slice(colon + 1);
|
||||
// Substring of the element's whole text, the way internal/hierarchy reads the
|
||||
// same selector: an element reading "Sent ✓" answers to text:Sent on every
|
||||
// platform, and one React wrote as `{count} unsent` answers to text:unsent
|
||||
// though its text arrives as two text nodes, which normalize-space(text())
|
||||
// reads only the first of. Anchored at the context node, so a scoped .find
|
||||
// reads its own subtree rather than the page.
|
||||
if (kind === "text") {
|
||||
return { xpath: `//*[normalize-space(text())=${xpathStringLiteral(value)}]` };
|
||||
return { xpath: innermostTextXPath(value) };
|
||||
}
|
||||
// The string form's kind space stays open: "<attr>:<value>" is the documented
|
||||
// way to reach a raw driver attribute, and internal/hierarchy resolves an
|
||||
|
||||
@@ -383,27 +383,33 @@ test("xpathStringLiteral table: quote handling stays well-formed", () => {
|
||||
});
|
||||
|
||||
// selectorFromString routes a "kind:value" prefix; text becomes an XPath
|
||||
// equality, everything else a CSS attribute selector. A value containing a
|
||||
// substring test, everything else a CSS attribute selector. A value containing a
|
||||
// colon must not be re-split, and a quote in a text value must reach the
|
||||
// well-formed XPath literal rather than corrupting the predicate.
|
||||
const { selectorFromString, selectorFromObject } = __testing__;
|
||||
|
||||
test("selectorFromString routes text to a normalize-space XPath", () => {
|
||||
test("selectorFromString routes text to a substring XPath", () => {
|
||||
assert.deepEqual(selectorFromString("text:Hello"), {
|
||||
xpath: `//*[normalize-space(text())="Hello"]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), "Hello") ` +
|
||||
`and not(.//*[contains(normalize-space(.), "Hello")])]`,
|
||||
});
|
||||
});
|
||||
|
||||
test("selectorFromString keeps colons in the value intact", () => {
|
||||
// Only the first colon splits kind from value; the rest is the value.
|
||||
assert.deepEqual(selectorFromString("text:a:b:c"), {
|
||||
xpath: `//*[normalize-space(text())="a:b:c"]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), "a:b:c") ` +
|
||||
`and not(.//*[contains(normalize-space(.), "a:b:c")])]`,
|
||||
});
|
||||
});
|
||||
|
||||
test("selectorFromString text value with both quote kinds uses concat", () => {
|
||||
assert.deepEqual(selectorFromString(`text:say "hi" o'clock`), {
|
||||
xpath: `//*[normalize-space(text())=concat("say ", '"', "hi", '"', " o'clock")]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), concat("say ", '"', "hi", '"', " o'clock")) ` +
|
||||
`and not(.//*[contains(normalize-space(.), concat("say ", '"', "hi", '"', " o'clock"))])]`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,11 +494,15 @@ test("selectorFromObject rejects a key no element can carry", () => {
|
||||
});
|
||||
|
||||
// Raw attributes the key list does not enumerate stay reachable when the page
|
||||
// actually carries them.
|
||||
// actually carries them, matched on a substring the way internal/hierarchy
|
||||
// matches the same key.
|
||||
test("selectorFromObject accepts a raw attribute the page carries", () => {
|
||||
withDocumentCarrying(["data-foo"], () => {
|
||||
assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), {
|
||||
css: `[data-foo="bar"]`,
|
||||
css: `[data-foo*="bar"]`,
|
||||
});
|
||||
assert.deepEqual(selectorFromObject({ "data-foo": "true" }), {
|
||||
css: `[data-foo="true"]`,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -502,7 +512,7 @@ test("selectorFromObject accepts a raw attribute the page carries", () => {
|
||||
// resolves an unknown kind to an empty result rather than an error.
|
||||
test("selectorFromString accepts a kind the object form would reject", () => {
|
||||
assert.deepEqual(selectorFromString("descripton:Supplier"), {
|
||||
css: `[descripton="Supplier"]`,
|
||||
css: `[descripton*="Supplier"]`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,7 +532,9 @@ function withDocumentCarrying(attributes: string[], run: () => void): void {
|
||||
|
||||
test("selectorFromObject text-only selector becomes an XPath", () => {
|
||||
assert.deepEqual(selectorFromObject({ text: "Go" }), {
|
||||
xpath: `//*[normalize-space(text())="Go"]`,
|
||||
xpath:
|
||||
`.//*[contains(normalize-space(.), "Go") ` +
|
||||
`and not(.//*[contains(normalize-space(.), "Go")])]`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user