mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
733 lines
22 KiB
Go
733 lines
22 KiB
Go
package hierarchy
|
|
|
|
import "testing"
|
|
|
|
// sampleDump is a Maestro TreeNode JSON equivalent of the old XML fixture.
|
|
const sampleDump = `{
|
|
"attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"resource-id": "app:id/title", "text": "Hello", "bounds": "[10,20,200,60]"},
|
|
"children": [],
|
|
"clickable": false,
|
|
"enabled": true
|
|
},
|
|
{
|
|
"attributes": {"resource-id": "app:id/row", "text": "Alice", "content-desc": "row", "bounds": "[0,100,1080,200]"},
|
|
"children": [],
|
|
"clickable": true,
|
|
"enabled": true
|
|
},
|
|
{
|
|
"attributes": {"resource-id": "app:id/row", "text": "Bob", "content-desc": "row", "bounds": "[0,200,1080,300]"},
|
|
"children": [],
|
|
"clickable": true,
|
|
"enabled": true
|
|
}
|
|
]
|
|
}`
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestDescPrefix(t *testing.T) {
|
|
input := `{
|
|
"attributes": {},
|
|
"children": [
|
|
{"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []},
|
|
{"attributes": {"content-desc": "customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []},
|
|
{"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []}
|
|
]
|
|
}`
|
|
tree, _ := Parse(input)
|
|
rows := tree.FindAll("descPrefix:customer_row_")
|
|
if len(rows) != 2 {
|
|
t.Fatalf("want 2 customer rows, got %d", len(rows))
|
|
}
|
|
}
|
|
|
|
func TestBoolFieldsFromNode(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"resource-id": "x", "bounds": "[0,0,100,100]"},
|
|
"children": [],
|
|
"clickable": true,
|
|
"enabled": false,
|
|
"focused": true,
|
|
"checked": true,
|
|
"selected": false
|
|
}`
|
|
tree, err := Parse(input)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if len(tree.Elements) != 1 {
|
|
t.Fatalf("want 1 element, got %d", len(tree.Elements))
|
|
}
|
|
el := tree.Elements[0]
|
|
if !el.Clickable {
|
|
t.Error("expected clickable=true")
|
|
}
|
|
if el.Enabled {
|
|
t.Error("expected enabled=false")
|
|
}
|
|
if !el.Focused {
|
|
t.Error("expected focused=true")
|
|
}
|
|
if !el.Checked {
|
|
t.Error("expected checked=true")
|
|
}
|
|
if el.Selected {
|
|
t.Error("expected selected=false")
|
|
}
|
|
}
|
|
|
|
func TestEditableDerivation(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
node string
|
|
want bool
|
|
}{
|
|
{"driver flag", `{"attributes": {"bounds": "[0,0,10,10]"}, "editable": true}`, true},
|
|
{"driver flag false", `{"attributes": {"bounds": "[0,0,10,10]"}, "editable": false}`, false},
|
|
{"native EditText class", `{"attributes": {"class": "android.widget.EditText", "bounds": "[0,0,10,10]"}}`, true},
|
|
{"hintText attr", `{"attributes": {"hintText": "Enter amount", "bounds": "[0,0,10,10]"}}`, true},
|
|
{"plain button", `{"attributes": {"class": "android.widget.Button", "bounds": "[0,0,10,10]"}}`, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
tree, err := Parse(tc.node)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
el := tree.Elements[0]
|
|
if el.Editable != tc.want {
|
|
t.Errorf("Editable = %v, want %v", el.Editable, tc.want)
|
|
}
|
|
if got := el.Attributes["editable"]; got != boolString(tc.want) {
|
|
t.Errorf("attrs[editable] = %q, want %q", got, boolString(tc.want))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func boolString(b bool) string {
|
|
if b {
|
|
return "true"
|
|
}
|
|
return "false"
|
|
}
|
|
|
|
// TestFindByEditableSelector confirms find({editable:true}) matches via the
|
|
// mirrored attrs entry, the same path clickable uses.
|
|
func TestFindByEditableSelector(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"resource-id": "root", "bounds": "[0,0,100,100]"},
|
|
"children": [
|
|
{"attributes": {"class": "android.widget.EditText", "bounds": "[0,0,100,40]"}, "children": []},
|
|
{"attributes": {"class": "android.widget.Button", "bounds": "[0,40,100,80]"}, "children": []}
|
|
]
|
|
}`
|
|
tree, _ := Parse(input)
|
|
if el := tree.Find("editable:true"); el == nil {
|
|
t.Fatal("expected to find the EditText via editable:true")
|
|
}
|
|
if matches := tree.FindAll("editable:true"); len(matches) != 1 {
|
|
t.Fatalf("editable:true matched %d elements, want 1", len(matches))
|
|
}
|
|
}
|
|
|
|
func TestIdentifierFallback(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"identifier": "my-button", "bounds": "[0,0,100,100]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
el := tree.Find("id:my-button")
|
|
if el == nil {
|
|
t.Fatal("expected match via identifier fallback")
|
|
}
|
|
}
|
|
|
|
func TestAccessibilityTextFallback(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"accessibilityText": "Close dialog", "bounds": "[0,0,50,50]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
el := tree.Find("desc:Close dialog")
|
|
if el == nil {
|
|
t.Fatal("expected match via accessibilityText fallback")
|
|
}
|
|
}
|
|
|
|
func TestIOSMergedLabel(t *testing.T) {
|
|
// iOS merges contentDescription with child text: "add_account_button, + Add account"
|
|
input := `{
|
|
"attributes": {"accessibilityText": "add_account_button, + Add account", "bounds": "[20,777][382,825]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
el := tree.Find("desc:add_account_button")
|
|
if el == nil {
|
|
t.Fatal("expected desc: to match iOS merged label")
|
|
}
|
|
if el.Bounds.Left != 20 || el.Bounds.Top != 777 || el.Bounds.Right != 382 || el.Bounds.Bottom != 825 {
|
|
t.Errorf("unexpected bounds: %+v", el.Bounds)
|
|
}
|
|
}
|
|
|
|
const pathDump = `{
|
|
"attributes": {"resource-id": "root", "bounds": "[0,0,1080,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"resource-id": "A", "content-desc": "screen_a", "bounds": "[0,0,540,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"resource-id": "B", "content-desc": "label_b", "bounds": "[0,0,100,100]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"resource-id": "C", "content-desc": "label_c", "bounds": "[0,0,50,50]"},
|
|
"children": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"attributes": {"resource-id": "A2", "content-desc": "screen_a2", "bounds": "[540,0,1080,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"resource-id": "B2", "content-desc": "label_b", "bounds": "[540,0,640,100]"},
|
|
"children": []
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}`
|
|
|
|
func TestPathQuerySingleLevel(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
el := tree.Find("id:A > id:B")
|
|
if el == nil {
|
|
t.Fatal("expected to find B under A")
|
|
}
|
|
if el.ResourceID != "B" {
|
|
t.Fatalf("got %q, want B", el.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestPathQueryNotBUnderOtherRoot(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
// B2 is under A2, not A; path from A should not reach B2
|
|
el := tree.Find("id:A > id:B2")
|
|
if el != nil {
|
|
t.Fatalf("expected nil, got element with id %q", el.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestPathQueryMultiLevel(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
el := tree.Find("id:A > id:B > id:C")
|
|
if el == nil {
|
|
t.Fatal("expected to find C under A > B")
|
|
}
|
|
if el.ResourceID != "C" {
|
|
t.Fatalf("got %q, want C", el.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestPathQueryMixedTypes(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
el := tree.Find("desc:screen_a > desc:label_b")
|
|
if el == nil {
|
|
t.Fatal("expected to find label_b under screen_a")
|
|
}
|
|
if el.Description != "label_b" {
|
|
t.Fatalf("got %q, want label_b", el.Description)
|
|
}
|
|
}
|
|
|
|
func TestPathQueryNotFound(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
if tree.Find("id:A > id:NoSuch") != nil {
|
|
t.Fatal("expected nil for missing second segment")
|
|
}
|
|
}
|
|
|
|
func TestPathQueryFirstMatchesSecondDoesNot(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
// B2 is under A2, so A > B2 should return nil (B2 is not a descendant of A)
|
|
if tree.Find("id:A > id:B2") != nil {
|
|
t.Fatal("B2 is not a descendant of A, expected nil")
|
|
}
|
|
}
|
|
|
|
func TestPathQueryFindAllAcrossRoots(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
// label_b appears under A (as B) and under A2 (as B2)
|
|
// FindAll("desc:screen_a > desc:label_b") should only find B under A, not B2 under A2
|
|
matches := tree.FindAll("desc:screen_a > desc:label_b")
|
|
if len(matches) != 1 {
|
|
t.Fatalf("want 1 match, got %d", len(matches))
|
|
}
|
|
if matches[0].ResourceID != "B" {
|
|
t.Fatalf("got %q, want B", matches[0].ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestPathQueryFindAllMultipleRootMatches(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
// Both A and A2 are children of root, both have a child with content-desc "label_b"
|
|
matches := tree.FindAll("id:root > desc:label_b")
|
|
if len(matches) != 2 {
|
|
t.Fatalf("want 2 matches (B and B2), got %d", len(matches))
|
|
}
|
|
}
|
|
|
|
func TestIOSBoundsFormat(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"accessibilityText": "account_card:abc123, Tim, $100", "bounds": "[20,130][382,202]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
el := tree.Find("descPrefix:account_card:")
|
|
if el == nil {
|
|
t.Fatal("expected descPrefix to match iOS account card")
|
|
}
|
|
if el.Bounds.Left != 20 || el.Bounds.Top != 130 || el.Bounds.Right != 382 || el.Bounds.Bottom != 202 {
|
|
t.Errorf("unexpected bounds: %+v", el.Bounds)
|
|
}
|
|
cx, cy := el.Bounds.Center()
|
|
if cx != 201 || cy != 166 {
|
|
t.Errorf("unexpected center: (%d, %d)", cx, cy)
|
|
}
|
|
}
|
|
|
|
// --- full-attribute selector tests ---
|
|
|
|
const androidAttrDump = `{
|
|
"attributes": {"resource-id": "com.app:id/list", "bounds": "[0,0,1080,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"resource-id": "com.app:id/row1", "scrollable": "true", "bounds": "[0,0,1080,200]"},
|
|
"children": [],
|
|
"clickable": true,
|
|
"enabled": true
|
|
},
|
|
{
|
|
"attributes": {"resource-id": "com.app:id/row2", "scrollable": "false", "bounds": "[0,200,1080,400]"},
|
|
"children": [],
|
|
"clickable": false,
|
|
"enabled": true
|
|
}
|
|
]
|
|
}`
|
|
|
|
const iosAttrDump = `{
|
|
"attributes": {"bounds": "[0,0,390,844]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"accessibilityText": "Close", "title": "Settings", "bounds": "[0,0,100,50]"},
|
|
"children": [],
|
|
"enabled": true
|
|
}
|
|
]
|
|
}`
|
|
|
|
func TestRawResourceIDSubstringMatch(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("resource-id:row1")
|
|
if el == nil {
|
|
t.Fatal("expected resource-id: to match via substring")
|
|
}
|
|
}
|
|
|
|
func TestLabelAliasMatchesAccessibilityText(t *testing.T) {
|
|
tree, _ := Parse(iosAttrDump)
|
|
el := tree.Find("label:Close")
|
|
if el == nil {
|
|
t.Fatal("expected label: to match accessibilityText via alias")
|
|
}
|
|
}
|
|
|
|
func TestContentDescAliasOnIOS(t *testing.T) {
|
|
tree, _ := Parse(iosAttrDump)
|
|
el := tree.Find("content-desc:Close")
|
|
if el == nil {
|
|
t.Fatal("expected content-desc: to match accessibilityText via alias on iOS")
|
|
}
|
|
}
|
|
|
|
func TestScrollableTrueMatches(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("scrollable:true")
|
|
if el == nil {
|
|
t.Fatal("expected scrollable:true to match")
|
|
}
|
|
if el.ResourceID != "com.app:id/row1" {
|
|
t.Fatalf("got %q, want row1", el.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestScrollableFalseMatchesSecondRow(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("scrollable:false")
|
|
if el == nil {
|
|
t.Fatal("expected scrollable:false to match row2")
|
|
}
|
|
if el.ResourceID != "com.app:id/row2" {
|
|
t.Fatalf("got %q, want row2", el.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestTitleMatchesIOSElement(t *testing.T) {
|
|
tree, _ := Parse(iosAttrDump)
|
|
el := tree.Find("title:Settings")
|
|
if el == nil {
|
|
t.Fatal("expected title:Settings to match iOS element")
|
|
}
|
|
}
|
|
|
|
func TestTitleReturnsNilForAndroid(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("title:Settings")
|
|
if el != nil {
|
|
t.Fatal("expected title:Settings to return nil for Android element (graceful ignore)")
|
|
}
|
|
}
|
|
|
|
func TestScrollableGracefulIgnoreOnIOS(t *testing.T) {
|
|
tree, _ := Parse(iosAttrDump)
|
|
el := tree.Find("scrollable:true")
|
|
if el != nil {
|
|
t.Fatal("expected scrollable:true to return nil on iOS hierarchy (graceful ignore)")
|
|
}
|
|
}
|
|
|
|
func TestTextIsNowSubstring(t *testing.T) {
|
|
tree, _ := Parse(sampleDump)
|
|
el := tree.Find("text:Hel")
|
|
if el == nil {
|
|
t.Fatal("expected text: to match substring")
|
|
}
|
|
if el.Text != "Hello" {
|
|
t.Fatalf("got %q, want Hello", el.Text)
|
|
}
|
|
}
|
|
|
|
func TestMultiFilterSelectorAND(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
sel := Selector{Filters: []AttrFilter{
|
|
{Attr: "scrollable", Value: "true"},
|
|
{Attr: "resource-id", Value: "row1"},
|
|
}}
|
|
node := tree.Root.FindBySelector(sel)
|
|
if node == nil {
|
|
t.Fatal("expected AND selector to find row1 (scrollable=true AND resource-id contains row1)")
|
|
}
|
|
if node.Element.ResourceID != "com.app:id/row1" {
|
|
t.Fatalf("got %q, want row1", node.Element.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestMultiFilterSelectorMissReturnsNil(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
sel := Selector{Filters: []AttrFilter{
|
|
{Attr: "scrollable", Value: "true"},
|
|
{Attr: "resource-id", Value: "row2"}, // row2 is not scrollable=true
|
|
}}
|
|
node := tree.Root.FindBySelector(sel)
|
|
if node != nil {
|
|
t.Fatal("expected AND selector to return nil when one filter misses")
|
|
}
|
|
}
|
|
|
|
func TestNodeFindScopedSearch(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
// A2 has a child B2 with content-desc "label_b"
|
|
// Find the A node, then search its subtree for label_b -- should find B (not B2)
|
|
aNode := tree.FindNode("id:A")
|
|
if aNode == nil {
|
|
t.Fatal("expected to find A node")
|
|
}
|
|
result := aNode.Find("desc:label_b")
|
|
if result == nil {
|
|
t.Fatal("expected Node.Find to find label_b in A's subtree")
|
|
}
|
|
if result.Element.ResourceID != "B" {
|
|
t.Fatalf("got %q, want B (not B2 from sibling A2)", result.Element.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestTestTagAliasMatchesResourceIDAndroid(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"resource-id": "AccountCard", "bounds": "[0,0,100,100]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}
|
|
if len(searchSubtreeBySelector(tree.Root, sel)) == 0 {
|
|
t.Fatal("expected testTag selector to match resource-id on Android")
|
|
}
|
|
}
|
|
|
|
func TestTestTagAliasMatchesAccessibilityIdentifierIOS(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"accessibilityIdentifier": "AccountCard", "bounds": "[0,0,100,100]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}
|
|
matches := searchSubtreeBySelector(tree.Root, sel)
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected testTag selector to match accessibilityIdentifier on iOS")
|
|
}
|
|
}
|
|
|
|
func TestTestTagAliasMatchesIdentifierIOSRaw(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"identifier": "AccountCard", "bounds": "[0,0,100,100]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
sel := Selector{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}}
|
|
matches := searchSubtreeBySelector(tree.Root, sel)
|
|
if len(matches) == 0 {
|
|
t.Fatal("expected testTag selector to match identifier on iOS raw AXElement")
|
|
}
|
|
}
|
|
|
|
func TestResourceIDFallsBackToAccessibilityIdentifier(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"accessibilityIdentifier": "MyButton", "bounds": "[0,0,100,100]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
sel := Selector{Filters: []AttrFilter{{Attr: "resource-id", Value: "MyButton"}}}
|
|
if len(searchSubtreeBySelector(tree.Root, sel)) == 0 {
|
|
t.Fatal("expected resource-id selector to fall back to accessibilityIdentifier")
|
|
}
|
|
}
|
|
|
|
func TestElementResourceIDPopulatesFromAccessibilityIdentifier(t *testing.T) {
|
|
input := `{
|
|
"attributes": {"accessibilityIdentifier": "LoginEmail", "bounds": "[0,0,100,100]"},
|
|
"children": []
|
|
}`
|
|
tree, _ := Parse(input)
|
|
if len(tree.Elements) == 0 {
|
|
t.Fatal("no elements parsed")
|
|
}
|
|
if got := tree.Elements[0].ResourceID; got != "LoginEmail" {
|
|
t.Fatalf("ResourceID = %q, want LoginEmail (iOS Compose accessibilityIdentifier path)", got)
|
|
}
|
|
}
|
|
|
|
const selectorPathDump = `{
|
|
"attributes": {"resource-id": "rootView", "bounds": "[0,0,1080,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"testTag": "HomeScreen", "bounds": "[0,0,540,2340]"},
|
|
"children": [
|
|
{
|
|
"attributes": {"testTag": "AccountCard", "bounds": "[0,0,540,200]"},
|
|
"children": [
|
|
{"attributes": {"testTag": "AccountName", "text": "Checking", "bounds": "[10,10,200,40]"}, "children": []}
|
|
]
|
|
},
|
|
{
|
|
"attributes": {"testTag": "AccountCard", "bounds": "[0,200,540,400]"},
|
|
"children": [
|
|
{"attributes": {"testTag": "AccountName", "text": "Savings", "bounds": "[10,210,200,240]"}, "children": []}
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"attributes": {"testTag": "LedgerScreen", "bounds": "[540,0,1080,2340]"},
|
|
"children": [
|
|
{"attributes": {"testTag": "AccountName", "text": "Travel", "bounds": "[600,10,800,40]"}, "children": []}
|
|
]
|
|
}
|
|
]
|
|
}`
|
|
|
|
func TestFindBySelectorPathSingleSegment(t *testing.T) {
|
|
tree, _ := Parse(selectorPathDump)
|
|
path := []Selector{{Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}}}
|
|
node := tree.FindBySelectorPath(path)
|
|
if node == nil {
|
|
t.Fatal("expected match for HomeScreen")
|
|
}
|
|
if got := node.Element.Attributes["testTag"]; got != "HomeScreen" {
|
|
t.Fatalf("testTag = %q, want HomeScreen", got)
|
|
}
|
|
}
|
|
|
|
func TestFindBySelectorPathScopedDescent(t *testing.T) {
|
|
tree, _ := Parse(selectorPathDump)
|
|
path := []Selector{
|
|
{Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}},
|
|
{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}},
|
|
{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}},
|
|
}
|
|
node := tree.FindBySelectorPath(path)
|
|
if node == nil {
|
|
t.Fatal("expected match for HomeScreen > AccountCard > AccountName")
|
|
}
|
|
if node.Element.Text != "Checking" {
|
|
t.Fatalf("text = %q, want Checking", node.Element.Text)
|
|
}
|
|
}
|
|
|
|
func TestFindBySelectorPathRespectsScope(t *testing.T) {
|
|
tree, _ := Parse(selectorPathDump)
|
|
path := []Selector{
|
|
{Filters: []AttrFilter{{Attr: "testTag", Value: "LedgerScreen"}}},
|
|
{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountCard"}}},
|
|
}
|
|
if node := tree.FindBySelectorPath(path); node != nil {
|
|
t.Fatalf("AccountCard is under HomeScreen only, expected nil, got %+v", node.Element)
|
|
}
|
|
}
|
|
|
|
func TestFindAllBySelectorPathReturnsAllDeepestMatches(t *testing.T) {
|
|
tree, _ := Parse(selectorPathDump)
|
|
path := []Selector{
|
|
{Filters: []AttrFilter{{Attr: "testTag", Value: "HomeScreen"}}},
|
|
{Filters: []AttrFilter{{Attr: "testTag", Value: "AccountName"}}},
|
|
}
|
|
matches := tree.FindAllBySelectorPath(path)
|
|
if len(matches) != 2 {
|
|
t.Fatalf("want 2 matches (Checking, Savings), got %d", len(matches))
|
|
}
|
|
}
|
|
|
|
func TestFindBySelectorPathEmptyPathReturnsNil(t *testing.T) {
|
|
tree, _ := Parse(selectorPathDump)
|
|
if tree.FindBySelectorPath(nil) != nil {
|
|
t.Fatal("empty path should return nil")
|
|
}
|
|
}
|
|
|
|
func TestNodeFindDoesNotReturnSiblings(t *testing.T) {
|
|
tree, _ := Parse(pathDump)
|
|
a2Node := tree.FindNode("id:A2")
|
|
if a2Node == nil {
|
|
t.Fatal("expected to find A2 node")
|
|
}
|
|
// B is under A, not A2 -- should not be found from A2's subtree
|
|
result := a2Node.Find("id:B")
|
|
if result != nil && result.Element.ResourceID == "B" {
|
|
t.Fatal("Node.Find should not return nodes from sibling subtrees")
|
|
}
|
|
}
|
|
|
|
// TestPackageDerivedFromResourceIDPrefix verifies that when the sidecar omits an
|
|
// explicit package attribute, native nodes pick it up from the resource-id
|
|
// prefix while colon-less Compose testTags stay empty (in scope for the app).
|
|
func TestPackageDerivedFromResourceIDPrefix(t *testing.T) {
|
|
const dump = `{
|
|
"attributes": {"class": "android.widget.FrameLayout", "bounds": "[0,0,320,640]"},
|
|
"children": [
|
|
{"attributes": {"resource-id": "AddAccountScreen", "bounds": "[0,0,320,400]"}, "clickable": true, "enabled": true, "children": []},
|
|
{"attributes": {"resource-id": "com.google.android.inputmethod.latin:id/key_pos_0_0", "bounds": "[0,400,40,440]"}, "clickable": true, "enabled": true, "children": []},
|
|
{"attributes": {"resource-id": "android:id/content", "bounds": "[0,0,320,640]"}, "children": []}
|
|
]
|
|
}`
|
|
tree, err := Parse(dump)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
want := map[string]string{
|
|
"AddAccountScreen": "",
|
|
"com.google.android.inputmethod.latin:id/key_pos_0_0": "com.google.android.inputmethod.latin",
|
|
"android:id/content": "android",
|
|
}
|
|
for _, element := range tree.Elements {
|
|
expected, ok := want[element.ResourceID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if element.Package != expected {
|
|
t.Errorf("resource-id %q: package = %q, want %q", element.ResourceID, element.Package, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExplicitPackageAttributeWins verifies an explicit package attribute is not
|
|
// overridden by the resource-id prefix fallback.
|
|
func TestExplicitPackageAttributeWins(t *testing.T) {
|
|
const dump = `{
|
|
"attributes": {"resource-id": "android:id/content", "package": "app.folio", "bounds": "[0,0,320,640]"},
|
|
"children": []
|
|
}`
|
|
tree, err := Parse(dump)
|
|
if err != nil {
|
|
t.Fatalf("Parse: %v", err)
|
|
}
|
|
if got := tree.Elements[0].Package; got != "app.folio" {
|
|
t.Errorf("package = %q, want app.folio (explicit attr should win)", got)
|
|
}
|
|
}
|