mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 03:57:09 +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
+101
-19
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user