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

+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")
}
}