mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
feat(hierarchy): XML parser and selector resolver for uiautomator dumps
This commit is contained in:
1 parent
e99c85c363
commit
e539e89438
2 files changed
+265
No files matched your search
@@ -0,0 +1,190 @@
|
||||
// Package hierarchy parses the XML produced by `uiautomator dump` and
|
||||
// resolves selectors against it.
|
||||
//
|
||||
// Selector grammar (v0.1):
|
||||
//
|
||||
// id:<suffix> — match resource-id ending with ":id/<suffix>" or equal to <suffix>
|
||||
// text:<value> — exact match on the node's text
|
||||
// desc:<value> — exact match on the node's content-desc
|
||||
package hierarchy
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Bounds is an inclusive rectangle in device pixels.
|
||||
type Bounds struct {
|
||||
Left, Top, Right, Bottom int
|
||||
}
|
||||
|
||||
// Center returns the center point of the bounds.
|
||||
func (b Bounds) Center() (int, int) {
|
||||
return (b.Left + b.Right) / 2, (b.Top + b.Bottom) / 2
|
||||
}
|
||||
|
||||
// Width returns the bounds' width.
|
||||
func (b Bounds) Width() int { return b.Right - b.Left }
|
||||
|
||||
// Height returns the bounds' height.
|
||||
func (b Bounds) Height() int { return b.Bottom - b.Top }
|
||||
|
||||
// Element is a flattened view of one uiautomator node.
|
||||
type Element struct {
|
||||
ResourceID string
|
||||
Text string
|
||||
Description string
|
||||
Class string
|
||||
Package string
|
||||
Clickable bool
|
||||
Enabled bool
|
||||
Bounds Bounds
|
||||
}
|
||||
|
||||
// Tree is a flat collection of every node in a hierarchy dump, in pre-order.
|
||||
type Tree struct {
|
||||
Elements []*Element
|
||||
}
|
||||
|
||||
// Parse parses a uiautomator-style XML dump.
|
||||
func Parse(xmlText string) (*Tree, error) {
|
||||
xmlText = strings.TrimSpace(xmlText)
|
||||
if xmlText == "" {
|
||||
return &Tree{}, nil
|
||||
}
|
||||
decoder := xml.NewDecoder(strings.NewReader(xmlText))
|
||||
tree := &Tree{}
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
start, ok := token.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if start.Name.Local != "node" {
|
||||
continue
|
||||
}
|
||||
element, parseErr := elementFromStart(start)
|
||||
if parseErr != nil {
|
||||
return nil, parseErr
|
||||
}
|
||||
tree.Elements = append(tree.Elements, element)
|
||||
}
|
||||
return tree, nil
|
||||
}
|
||||
|
||||
// Find returns the first element matching the selector, or nil.
|
||||
func (t *Tree) Find(selector string) *Element {
|
||||
kind, value, ok := parseSelector(selector)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, element := range t.Elements {
|
||||
if match(element, kind, value) {
|
||||
return element
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindAll returns every element matching the selector.
|
||||
func (t *Tree) FindAll(selector string) []*Element {
|
||||
kind, value, ok := parseSelector(selector)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var matches []*Element
|
||||
for _, element := range t.Elements {
|
||||
if match(element, kind, value) {
|
||||
matches = append(matches, element)
|
||||
}
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
func parseSelector(selector string) (string, string, bool) {
|
||||
index := strings.IndexByte(selector, ':')
|
||||
if index <= 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return selector[:index], selector[index+1:], true
|
||||
}
|
||||
|
||||
func match(element *Element, kind, value string) bool {
|
||||
switch kind {
|
||||
case "id":
|
||||
if element.ResourceID == value {
|
||||
return true
|
||||
}
|
||||
// ResourceID looks like "<package>:id/<suffix>".
|
||||
if strings.HasSuffix(element.ResourceID, ":id/"+value) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
case "text":
|
||||
return element.Text == value
|
||||
case "desc":
|
||||
return element.Description == value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func elementFromStart(start xml.StartElement) (*Element, error) {
|
||||
element := &Element{}
|
||||
var boundsText string
|
||||
for _, attribute := range start.Attr {
|
||||
switch attribute.Name.Local {
|
||||
case "resource-id":
|
||||
element.ResourceID = attribute.Value
|
||||
case "text":
|
||||
element.Text = attribute.Value
|
||||
case "content-desc":
|
||||
element.Description = attribute.Value
|
||||
case "class":
|
||||
element.Class = attribute.Value
|
||||
case "package":
|
||||
element.Package = attribute.Value
|
||||
case "clickable":
|
||||
element.Clickable = attribute.Value == "true"
|
||||
case "enabled":
|
||||
element.Enabled = attribute.Value == "true"
|
||||
case "bounds":
|
||||
boundsText = attribute.Value
|
||||
}
|
||||
}
|
||||
if boundsText != "" {
|
||||
bounds, err := parseBounds(boundsText)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bounds %q: %w", boundsText, err)
|
||||
}
|
||||
element.Bounds = bounds
|
||||
}
|
||||
return element, nil
|
||||
}
|
||||
|
||||
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$`)
|
||||
|
||||
func parseBounds(text string) (Bounds, error) {
|
||||
match := boundsPattern.FindStringSubmatch(text)
|
||||
if match == nil {
|
||||
return Bounds{}, fmt.Errorf("not in [L,T][R,B] form")
|
||||
}
|
||||
coordinates := make([]int, 4)
|
||||
for index := range 4 {
|
||||
value, err := strconv.Atoi(match[index+1])
|
||||
if err != nil {
|
||||
return Bounds{}, err
|
||||
}
|
||||
coordinates[index] = value
|
||||
}
|
||||
return Bounds{Left: coordinates[0], Top: coordinates[1], Right: coordinates[2], Bottom: coordinates[3]}, nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package hierarchy
|
||||
|
||||
import "testing"
|
||||
|
||||
const sampleDump = `<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node class="android.widget.LinearLayout" package="app" bounds="[0,0][1080,2340]">
|
||||
<node resource-id="app:id/title" text="Hello" bounds="[10,20][200,60]" clickable="false" enabled="true"/>
|
||||
<node resource-id="app:id/row" text="Alice" content-desc="row" bounds="[0,100][1080,200]" clickable="true" enabled="true"/>
|
||||
<node resource-id="app:id/row" text="Bob" content-desc="row" bounds="[0,200][1080,300]" clickable="true" enabled="true"/>
|
||||
</node>
|
||||
</hierarchy>`
|
||||
|
||||
func TestParseCountsNodes(t *testing.T) {
|
||||
tree, err := Parse(sampleDump)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(tree.Elements) != 4 {
|
||||
t.Fatalf("want 4 elements, got %d", len(tree.Elements))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByIDSuffix(t *testing.T) {
|
||||
tree, _ := Parse(sampleDump)
|
||||
element := tree.Find("id:title")
|
||||
if element == nil {
|
||||
t.Fatal("expected match for id:title")
|
||||
}
|
||||
if element.Text != "Hello" {
|
||||
t.Fatalf("unexpected text %q", element.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByText(t *testing.T) {
|
||||
tree, _ := Parse(sampleDump)
|
||||
element := tree.Find("text:Alice")
|
||||
if element == nil {
|
||||
t.Fatal("expected match for text:Alice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAllReturnsDuplicates(t *testing.T) {
|
||||
tree, _ := Parse(sampleDump)
|
||||
elements := tree.FindAll("id:row")
|
||||
if len(elements) != 2 {
|
||||
t.Fatalf("want 2, got %d", len(elements))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundsCenter(t *testing.T) {
|
||||
tree, _ := Parse(sampleDump)
|
||||
element := tree.Find("text:Alice")
|
||||
x, y := element.Bounds.Center()
|
||||
if x != 540 || y != 150 {
|
||||
t.Fatalf("unexpected center %d,%d", x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEmpty(t *testing.T) {
|
||||
tree, err := Parse("")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
if len(tree.Elements) != 0 {
|
||||
t.Fatalf("want empty, got %d", len(tree.Elements))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownSelector(t *testing.T) {
|
||||
tree, _ := Parse(sampleDump)
|
||||
if tree.Find("bogus:value") != nil {
|
||||
t.Fatal("unknown kind should not match")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user