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:
pj committed 2026-08-16 17:43:10 +05:30
1 parent a245194120
commit ca667b1b77
9 files changed
+526 -49

No files matched your search

+204 -4
View File
@@ -102,6 +102,21 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) {
object: objectSelector("data-testid", "customer-row"),
want: []string{"customer_row_a1", "customer_row_b2"},
},
{
name: "raw attribute, matched on a substring",
selector: "data-state:sent",
object: objectSelector("data-state", "sent"),
want: []string{"status_badge"},
},
{
// The root element answers a selector like any other: the string
// form scans from the root down, and the object form used to start
// at the root's children and lose it.
name: "id naming the root element",
selector: "id:page",
object: objectSelector("id", "page"),
want: []string{"page"},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
@@ -129,6 +144,189 @@ func TestSelectors_ResolveTheSameElementsAsTheWebRuntime(t *testing.T) {
}
}
// `text:` is a substring match on text content wherever the spec runs
// (docs/manual/spec-language.md), so a badge reading "Sent ✓" answers to
// text:Sent on web the way it already does on Android and iOS, and one reading
// "3 unsent" out of two text nodes answers to text:unsent. It names the
// innermost match: an element's text is its whole subtree's text, so a badge's
// ancestors up to the document root read as matches too, and the deepest one is
// the element the author meant.
func TestSelectors_TextNamesTheInnermostMatchInEveryResolver(t *testing.T) {
server := httptest.NewServer(http.FileServer(http.Dir("testdata")))
defer server.Close()
d := New()
defer d.Terminate(context.Background())
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := d.Launch(ctx, server.URL+"/selector-parity.html", false, nil); err != nil {
t.Fatalf("Launch: %v", err)
}
dump, err := d.Hierarchy(ctx)
if err != nil {
t.Fatalf("Hierarchy: %v", err)
}
tree, err := hierarchy.Parse(dump)
if err != nil {
t.Fatalf("parse hierarchy: %v", err)
}
installSelectorProbe(ctx, t, d)
// split_row is the ancestor that keeps its match: its own text carries the
// value where no descendant of it does. nested_row is the ancestor that
// loses it: its badge carries the value too, and the badge is the match.
cases := []struct {
name string
selector string
want []string
scope string
scopedWant []string
}{
{
name: "one text node",
selector: "text:Sent",
want: []string{
"status_badge",
"draft_badge",
"split_row",
"nested_badge",
},
scope: "status_row",
scopedWant: []string{"status_badge"},
},
{
// React writes `{count} unsent` as two text nodes, and an XPath over
// text() reads only the first of them.
name: "text split across text nodes",
selector: "text:unsent",
want: []string{"unsent_badge"},
scope: "unsent_row",
scopedWant: []string{"unsent_badge"},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
value := strings.TrimPrefix(testCase.selector, "text:")
if native := selectorIDsFromDump(tree, testCase.selector); !slices.Equal(
native,
testCase.want,
) {
t.Errorf(
"the dump matched %v for %s, want %v",
native,
testCase.selector,
testCase.want,
)
}
if web := selectorIDsFromWebRuntime(ctx, t, d, testCase.selector); !slices.Equal(
web,
testCase.want,
) {
t.Errorf(
"the web runtime matched %v for %s, want %v",
web,
testCase.selector,
testCase.want,
)
}
object := objectSelectorJSON(objectSelector("text", value))
if web := selectorIDsFromWebRuntime(ctx, t, d, object); !slices.Equal(
web,
testCase.want,
) {
t.Errorf(
"the web runtime matched %v for %s, want %v",
web,
object,
testCase.want,
)
}
xpath, isXPath, err := TranslateStringSelector(testCase.selector)
if err != nil {
t.Fatalf(
"TranslateStringSelector(%q): %v",
testCase.selector,
err,
)
}
if !isXPath {
t.Fatalf(
"TranslateStringSelector(%q) returned CSS, want an XPath",
testCase.selector,
)
}
if overCDP := xpathIDsOverCDP(ctx, t, d, xpath); !slices.Equal(
overCDP,
testCase.want,
) {
t.Errorf(
"the CDP selector %q matched %v, want %v",
xpath,
overCDP,
testCase.want,
)
}
// Scoped to one row, `text:` reads that row, the way every other
// selector does: an XPath anchored at the document root answers for
// the whole page however the caller scoped the lookup.
path := []hierarchy.Selector{
objectSelector("id", testCase.scope),
objectSelector("text", value),
}
var scoped []string
for _, node := range tree.FindAllBySelectorPath(path) {
scoped = append(scoped, node.Element.ResourceID)
}
if !slices.Equal(scoped, testCase.scopedWant) {
t.Errorf(
"the dump matched %v within %s, want %v",
scoped,
testCase.scope,
testCase.scopedWant,
)
}
pathJSON := objectSelectorJSON(objectSelector("id", testCase.scope))
pathJSON = "[" + pathJSON + "," + object + "]"
if web := selectorIDsFromWebRuntime(ctx, t, d, pathJSON); !slices.Equal(
web,
testCase.scopedWant,
) {
t.Errorf(
"the web runtime matched %v for %s, want %v",
web,
pathJSON,
testCase.scopedWant,
)
}
})
}
}
// xpathIDsOverCDP resolves an XPath the way TapSelector does, over CDP against
// the live document, and reads back the ids it matched in document order.
func xpathIDsOverCDP(
ctx context.Context,
t *testing.T,
d *Driver,
xpath string,
) []string {
t.Helper()
var ids []string
script := `(() => {
const found = document.evaluate(` + jsArgument(xpath) + `, document, null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
const ids = [];
for (let i = 0; i < found.snapshotLength; i++) ids.push(found.snapshotItem(i).id);
return ids;
})()`
if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &ids)); err != nil {
t.Fatalf("resolve %q over CDP: %v", xpath, err)
}
return ids
}
// A third resolver reads the same selector: TapSelector hands it to CDP as the
// CSS TranslateStringSelector builds. The runner uses both within one InputText
// step, resolving the target in the dump and tapping it over CDP, so a selector
@@ -197,16 +395,18 @@ func objectSelectorJSON(sel hierarchy.Selector) string {
func selectorIDsFromDumpObject(tree *hierarchy.Tree, sel hierarchy.Selector) []string {
var ids []string
for _, node := range tree.Root.FindAllBySelector(sel) {
for _, node := range tree.FindAllBySelector(sel) {
ids = append(ids, node.Element.ResourceID)
}
return ids
}
// jsArgument passes an object selector through as the JS object literal it
// already is, and quotes anything else as a string selector.
// jsArgument passes an object or path selector through as the JS literal it
// already is, and quotes anything else as a string selector. A CSS attribute
// selector opens with a bracket too, so only well-formed JSON passes through.
func jsArgument(selector string) string {
if strings.HasPrefix(selector, "{") {
if json.Valid([]byte(selector)) &&
(strings.HasPrefix(selector, "{") || strings.HasPrefix(selector, "[")) {
return selector
}
quoted, err := json.Marshal(selector)
+5
View File
@@ -11,5 +11,10 @@
<div id="customer_row_b2" data-testid="customer-row" aria-label="customer_row_b2, Bob">Bob</div>
<div id="supplier_row_c3" aria-label="supplier_row_c3, Carol">Carol</div>
</div>
<div id="status_row"><span id="status_badge" data-state="sent-badge">Sent ✓</span></div>
<div id="draft_row"><span id="draft_badge">Sent ✓</span></div>
<div id="unsent_row"><span id="unsent_badge">3<!-- --> unsent</span></div>
<div id="split_row">Sen<span id="split_tail">t here</span></div>
<div id="nested_row">Sent <span id="nested_badge">Sent</span></div>
</body>
</html>
+24 -2
View File
@@ -39,7 +39,14 @@ func TranslateStringSelector(selector string) (string, bool, error) {
case "tag":
return cssEscape(value), false, nil
case "text":
return `//*[normalize-space(text())=` + xpathStringLiteral(value) + `]`, true, nil
// 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.
// normalize-space(text()) would read only the first of them. The
// not() clause is what keeps the badge's ancestors, up to <html>, from
// answering for it.
return `//*[` + innermostTextPredicate(value) + `]`, true, nil
case "desc":
// Mirrors the native rule: the label itself, or the label at the head of
// an iOS merged label ("account_card:7, Tim, $100").
@@ -63,7 +70,11 @@ func TranslateStringSelector(selector string) (string, bool, error) {
if !attrNamePattern.MatchString(kind) {
return "", false, fmt.Errorf("unsafe selector prefix %q", kind)
}
return `[` + kind + `="` + cssEscape(value) + `"]`, false, nil
operator := `*=`
if value == "true" || value == "false" {
operator = `=`
}
return `[` + kind + operator + `"` + cssEscape(value) + `"]`, false, nil
}
}
@@ -95,6 +106,17 @@ func cssEscape(value string) string {
return builder.String()
}
// innermostTextPredicate matches an element whose text contains value and whose
// descendants do not, which is the innermost match internal/hierarchy resolves
// the same selector to. The same predicate appears in
// pkg/spec/src/web-runtime.ts.
func innermostTextPredicate(value string) string {
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(). The output already includes the surrounding quotes
+35 -6
View File
@@ -12,10 +12,26 @@ func TestTranslateStringSelector_KnownKeys(t *testing.T) {
{"resource-id:account-name", `[id="account-name"]`, false},
{"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},
{
"text:Sign in",
`//*[contains(normalize-space(.), "Sign in") and not(.//*[contains(normalize-space(.), "Sign in")])]`,
true,
},
{
`text:Say "hi"`,
`//*[contains(normalize-space(.), 'Say "hi"') and not(.//*[contains(normalize-space(.), 'Say "hi"')])]`,
true,
},
{
`text:it's`,
`//*[contains(normalize-space(.), "it's") and not(.//*[contains(normalize-space(.), "it's")])]`,
true,
},
{
`text:it's "fine"`,
`//*[contains(normalize-space(.), concat("it's ", '"', "fine", '"', "")) and not(.//*[contains(normalize-space(.), concat("it's ", '"', "fine", '"', ""))])]`,
true,
},
// desc also accepts an iOS merged label, the way internal/hierarchy does.
{"desc:logout", `:is([aria-label="logout"], [aria-label^="logout, "])`, false},
{"label:logout", `[aria-label="logout"]`, false},
@@ -47,8 +63,21 @@ func TestTranslateStringSelector_UnknownPrefixPassesThrough(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got != `[role="button"]` {
t.Errorf("unknown prefix should map to attribute selector, got %q", got)
if got != `[role*="button"]` {
t.Errorf(
"unknown prefix should map to a substring attribute selector, got %q",
got,
)
}
got, _, err = TranslateStringSelector("aria-expanded:true")
if err != nil {
t.Fatal(err)
}
if got != `[aria-expanded="true"]` {
t.Errorf(
"a boolean value should map to an exact attribute selector, got %q",
got,
)
}
}
+101 -19
View File
@@ -7,7 +7,7 @@
// attribute:value - substring match; exact for "true"/"false" booleans
// id:<suffix> - substring on resource-id / identifier (backward compat)
// idPrefix:<prefix> - starts-with on resource-id / identifier, package prefix skipped
// text:<value> - substring on text attribute
// text:<value> - substring on text attribute, innermost match only
// desc:<value> - substring on content-desc / accessibilityText
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
//
@@ -325,6 +325,46 @@ func matchSelector(element *Element, sel Selector) bool {
return true
}
func selectorReadsText(sel Selector) bool {
for _, f := range sel.Filters {
if f.Attr == "text" {
return true
}
}
return false
}
// innermostMatches drops a match a descendant of it also makes. An element's
// text is its whole subtree's text on web and on iOS, so every ancestor of a
// matching element matches too, up to the root, and the deepest match is the
// element the author named. An ancestor whose own text carries the value where
// no descendant of it does keeps its match.
func innermostMatches(nodes []*Node) []*Node {
if len(nodes) == 0 {
return nodes
}
matched := make(map[*Node]bool, len(nodes))
for _, node := range nodes {
matched[node] = true
}
var kept []*Node
for _, node := range nodes {
if !hasMatchingDescendant(node, matched) {
kept = append(kept, node)
}
}
return kept
}
func hasMatchingDescendant(node *Node, matched map[*Node]bool) bool {
for _, child := range node.Children {
if matched[child] || hasMatchingDescendant(child, matched) {
return true
}
}
return false
}
// Parse parses a sidecar TreeNode JSON hierarchy.
func Parse(text string) (*Tree, error) {
text = strings.TrimSpace(text)
@@ -497,20 +537,54 @@ func (t *Tree) FindAllNodes(selector string) []*Node {
return searchSubtree(t.Root, kind, value)
}
// FindBySelectorPath walks the selector chain starting from the tree root.
func (t *Tree) FindBySelectorPath(path []Selector) *Node {
if t == nil || t.Root == nil {
// FindBySelector returns the first Node in the tree matching sel, or nil. The
// root is a candidate, the way it is for the string form: one selector cannot
// mean one thing written "id:page" and another written {id: "page"}.
func (t *Tree) FindBySelector(sel Selector) *Node {
if t == nil {
return nil
}
return t.Root.FindBySelectorPath(path)
return firstNode(searchSubtreeBySelector(t.Root, sel))
}
// FindAllBySelector returns every Node in the tree matching sel, root included.
func (t *Tree) FindAllBySelector(sel Selector) []*Node {
if t == nil {
return nil
}
return searchSubtreeBySelector(t.Root, sel)
}
// FindBySelectorPath walks the selector chain starting from the tree root.
func (t *Tree) FindBySelectorPath(path []Selector) *Node {
if t == nil || t.Root == nil || len(path) == 0 {
return nil
}
for _, candidate := range t.FindAllBySelector(path[0]) {
if len(path) == 1 {
return candidate
}
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
return deeper
}
}
return nil
}
// FindAllBySelectorPath walks the selector chain starting from the tree root.
func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node {
if t == nil || t.Root == nil {
if t == nil || t.Root == nil || len(path) == 0 {
return nil
}
return t.Root.FindAllBySelectorPath(path)
var result []*Node
for _, candidate := range t.FindAllBySelector(path[0]) {
if len(path) == 1 {
result = append(result, candidate)
continue
}
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
}
return result
}
// Find returns the first Node scoped to this node (descendants, with spatial
@@ -527,9 +601,13 @@ func (n *Node) FindAll(selector string) []*Node {
if !ok {
return nil
}
return n.scopedNodes(func(element *Element) bool {
nodes := n.scopedNodes(func(element *Element) bool {
return matchAttr(element, kind, value)
})
if kind == "text" {
return innermostMatches(nodes)
}
return nodes
}
// FindBySelector returns the first Node scoped to this node matching sel (AND semantics).
@@ -539,9 +617,13 @@ func (n *Node) FindBySelector(sel Selector) *Node {
// FindAllBySelector returns all Nodes scoped to this node matching sel (AND semantics).
func (n *Node) FindAllBySelector(sel Selector) []*Node {
return n.scopedNodes(func(element *Element) bool {
nodes := n.scopedNodes(func(element *Element) bool {
return matchSelector(element, sel)
})
if selectorReadsText(sel) {
return innermostMatches(nodes)
}
return nodes
}
// FindBySelectorPath walks a chain of selectors. The first selector is matched
@@ -729,11 +811,11 @@ func searchSubtree(root *Node, kind, value string) []*Node {
return nil
}
var result []*Node
if matchAttr(&root.Element, kind, value) {
result = append(result, root)
}
for _, child := range root.Children {
result = append(result, searchSubtree(child, kind, value)...)
collectMatches(root, func(element *Element) bool {
return matchAttr(element, kind, value)
}, &result)
if kind == "text" {
return innermostMatches(result)
}
return result
}
@@ -744,11 +826,11 @@ func searchSubtreeBySelector(root *Node, sel Selector) []*Node {
return nil
}
var result []*Node
if matchSelector(&root.Element, sel) {
result = append(result, root)
}
for _, child := range root.Children {
result = append(result, searchSubtreeBySelector(child, sel)...)
collectMatches(root, func(element *Element) bool {
return matchSelector(element, sel)
}, &result)
if selectorReadsText(sel) {
return innermostMatches(result)
}
return result
}
+110 -2
View File
@@ -1,6 +1,7 @@
package hierarchy
import (
"slices"
"strings"
"testing"
)
@@ -575,6 +576,113 @@ func TestTextIsNowSubstring(t *testing.T) {
}
}
// subtreeTextDump is the shape web and iOS report: an element's text is its
// whole subtree's text, so a badge's ancestors carry the badge's words.
// split_row is the ancestor whose own text carries the value where no
// descendant of it does; nested_row is the one whose badge carries it too.
const subtreeTextDump = `{
"attributes": {"resource-id": "page", "text": "Sent Sent here Sent Sent", "bounds": "[0,0,1080,2340]"},
"children": [
{
"attributes": {"resource-id": "status_row", "text": "Sent", "bounds": "[0,0,1080,100]"},
"children": [
{"attributes": {"resource-id": "status_badge", "text": "Sent", "bounds": "[0,0,200,100]"}, "children": []}
]
},
{
"attributes": {"resource-id": "split_row", "text": "Sent here", "bounds": "[0,100,1080,200]"},
"children": [
{"attributes": {"resource-id": "split_tail", "text": "t here", "bounds": "[0,100,200,200]"}, "children": []}
]
},
{
"attributes": {"resource-id": "nested_row", "text": "Sent Sent", "bounds": "[0,200,1080,300]"},
"children": [
{"attributes": {"resource-id": "nested_badge", "text": "Sent", "bounds": "[0,200,200,300]"}, "children": []}
]
}
]
}`
func TestTextNamesTheInnermostMatch(t *testing.T) {
tree, _ := Parse(subtreeTextDump)
want := []string{"status_badge", "split_row", "nested_badge"}
if got := resourceIDsOf(tree.FindAllNodes("text:Sent")); !slices.Equal(
got,
want,
) {
t.Errorf("text:Sent matched %v, want %v", got, want)
}
sel := Selector{Filters: []AttrFilter{{Attr: "text", Value: "Sent"}}}
if got := resourceIDsOf(tree.FindAllBySelector(sel)); !slices.Equal(
got,
want,
) {
t.Errorf("{text: Sent} matched %v, want %v", got, want)
}
node := tree.FindNode("text:Sent")
if node == nil || node.ResourceID != "status_badge" {
t.Errorf("find named %v, want the deepest match status_badge", node)
}
}
func TestScopedTextNamesTheInnermostMatch(t *testing.T) {
tree, _ := Parse(subtreeTextDump)
want := []string{"status_badge", "split_row", "nested_badge"}
if got := resourceIDsOf(tree.Root.FindAll("text:Sent")); !slices.Equal(
got,
want,
) {
t.Errorf("scoped text:Sent matched %v, want %v", got, want)
}
sel := Selector{Filters: []AttrFilter{{Attr: "text", Value: "Sent"}}}
if got := resourceIDsOf(tree.Root.FindAllBySelector(sel)); !slices.Equal(
got,
want,
) {
t.Errorf("scoped {text: Sent} matched %v, want %v", got, want)
}
}
// The root answers a selector whichever form the selector is written in: the
// string form scans the tree from the root down, and the object form used to
// start at the root's children and lose it.
func TestRootMatchesInBothSelectorForms(t *testing.T) {
tree, _ := Parse(subtreeTextDump)
sel := Selector{Filters: []AttrFilter{{Attr: "id", Value: "page"}}}
want := []string{"page"}
if got := resourceIDsOf(tree.FindAllNodes("id:page")); !slices.Equal(
got,
want,
) {
t.Errorf("id:page matched %v, want %v", got, want)
}
if got := resourceIDsOf(tree.FindAllBySelector(sel)); !slices.Equal(
got,
want,
) {
t.Errorf("{id: page} matched %v, want %v", got, want)
}
if got := resourceIDsOf(tree.FindAllBySelectorPath([]Selector{sel})); !slices.Equal(
got,
want,
) {
t.Errorf("[{id: page}] matched %v, want %v", got, want)
}
if node := tree.FindBySelector(sel); node == nil ||
node.ResourceID != "page" {
t.Errorf("find({id: page}) named %v, want page", node)
}
}
func resourceIDsOf(nodes []*Node) []string {
var ids []string
for _, node := range nodes {
ids = append(ids, node.ResourceID)
}
return ids
}
func TestMultiFilterSelectorAND(t *testing.T) {
tree, _ := Parse(androidAttrDump)
sel := Selector{Filters: []AttrFilter{
@@ -1142,7 +1250,7 @@ func TestTreeTransitional(t *testing.T) {
func TestObjectSelectorIDMatchesTheSameElementsAsTheStringForm(t *testing.T) {
tree, _ := Parse(sampleDump)
sel := Selector{Filters: []AttrFilter{{Attr: "id", Value: "row"}}}
object := tree.Root.FindAllBySelector(sel)
object := tree.FindAllBySelector(sel)
if len(object) != len(tree.FindAll("id:row")) {
t.Fatalf("object form matched %d, string form %d", len(object), len(tree.FindAll("id:row")))
}
@@ -1154,7 +1262,7 @@ func TestObjectSelectorIDMatchesTheSameElementsAsTheStringForm(t *testing.T) {
func TestObjectSelectorDescMatchesTheSameElementsAsTheStringForm(t *testing.T) {
tree, _ := Parse(sampleDump)
sel := Selector{Filters: []AttrFilter{{Attr: "desc", Value: "row"}}}
if len(tree.Root.FindAllBySelector(sel)) != len(tree.FindAll("desc:row")) {
if len(tree.FindAllBySelector(sel)) != len(tree.FindAll("desc:row")) {
t.Fatal("object and string form disagree on desc")
}
}
+2 -2
View File
@@ -184,7 +184,7 @@ func findNodeFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Value)
return nil
}
requireKnownSelectorKeys(runtime, tree, sel)
return tree.Root.FindBySelector(sel)
return tree.FindBySelector(sel)
}
// findAllNodesFromJS dispatches a JS value to Tree-level multi-node lookup.
@@ -204,7 +204,7 @@ func findAllNodesFromJS(runtime *goja.Runtime, tree *hierarchy.Tree, arg goja.Va
return nil
}
requireKnownSelectorKeys(runtime, tree, sel)
return tree.Root.FindAllBySelector(sel)
return tree.FindAllBySelector(sel)
}
// findNodeInSubtreeFromJS dispatches a JS value to Node-level scoped lookup.
+24 -5
View File
@@ -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
+21 -9
View File
@@ -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")])]`,
});
});