Files
sanderling/internal/hierarchy/hierarchy_test.go
T
pj db43f450bc refactor(hierarchy): name no screen for a tree Transitional calls a cross-fade
ScreenName kept its own reading of the route markers and disagreed with
Transitional on a marker repeated by a nested node: it named the screen on
a step the runner was skipping as unsettled. One reading now.
2026-09-05 22:31:17 +05:30

1869 lines
61 KiB
Go

package hierarchy
import (
"encoding/json"
"slices"
"strings"
"testing"
)
// sampleDump is a sidecar 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))
}
}
// idPrefixDump is a list whose rows carry a durable role prefix followed by the
// record's identifier, the convention that makes every row's full id unique and
// unwritable in a spec.
const idPrefixDump = `{
"attributes": {"resource-id": "com.example:id/customer_list", "bounds": "[0,0,100,400]"},
"children": [
{"attributes": {"resource-id": "com.example:id/customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []},
{"attributes": {"resource-id": "com.example:id/customer_row_def-456", "bounds": "[0,100,100,200]"}, "children": []},
{"attributes": {"resource-id": "com.example:id/supplier_row_xyz", "bounds": "[0,200,100,300]"}, "children": []},
{"attributes": {"identifier": "customer_row_ghi-789", "bounds": "[0,300,100,400]"}, "children": []}
]
}`
func TestIDPrefixMatchesEveryRowSharingTheRole(t *testing.T) {
tree, _ := Parse(idPrefixDump)
rows := tree.FindAll("idPrefix:customer_row_")
if len(rows) != 3 {
t.Fatalf("want 3 customer rows, got %d", len(rows))
}
}
func TestIDPrefixDoesNotRequireThePackagePrefix(t *testing.T) {
tree, _ := Parse(idPrefixDump)
el := tree.Find("idPrefix:customer_row_abc")
if el == nil {
t.Fatal("expected the local name after :id/ to match on its own")
}
if el.ResourceID != "com.example:id/customer_row_abc-123" {
t.Fatalf("got %q", el.ResourceID)
}
}
func TestIDPrefixAlsoMatchesTheWholeIdentifier(t *testing.T) {
tree, _ := Parse(idPrefixDump)
if tree.Find("idPrefix:com.example:id/customer_row_") == nil {
t.Fatal("expected a package-qualified prefix to match")
}
}
func TestIDPrefixMatchesIOSAccessibilityIdentifier(t *testing.T) {
tree, _ := Parse(idPrefixDump)
el := tree.Find("idPrefix:customer_row_ghi")
if el == nil {
t.Fatal("expected identifier to match on a node with no resource-id")
}
if el.Bounds.Top != 300 {
t.Fatalf("matched the wrong node: %+v", el.Bounds)
}
}
func TestIDPrefixMatchesNothingWhenNoIDStartsWithIt(t *testing.T) {
tree, _ := Parse(idPrefixDump)
if rows := tree.FindAll("idPrefix:invoice_row_"); len(rows) != 0 {
t.Fatalf("want no matches, got %d", len(rows))
}
}
func TestIDPrefixIsNotASubstringMatch(t *testing.T) {
tree, _ := Parse(idPrefixDump)
if tree.Find("idPrefix:row_") != nil {
t.Fatal("expected starts-with, not substring")
}
}
// The string and object forms are one rule, so a prefix filter combined with a
// second attribute has to keep the same meaning it has on its own.
func TestIDPrefixInObjectSelector(t *testing.T) {
tree, _ := Parse(idPrefixDump)
sel := Selector{Filters: []AttrFilter{{Attr: "idPrefix", Value: "customer_row_"}}}
if nodes := tree.Root.FindAllBySelector(sel); len(nodes) != 3 {
t.Fatalf("want 3 customer rows, got %d", len(nodes))
}
}
func TestDescPrefixInObjectSelector(t *testing.T) {
input := `{
"attributes": {},
"children": [
{"attributes": {"content-desc": "customer_row_abc-123", "bounds": "[0,0,100,100]"}, "children": []},
{"attributes": {"content-desc": "supplier_row_xyz", "bounds": "[0,100,100,200]"}, "children": []}
]
}`
tree, _ := Parse(input)
sel := Selector{Filters: []AttrFilter{{Attr: "descPrefix", Value: "customer_row_"}}}
if nodes := tree.Root.FindAllBySelector(sel); len(nodes) != 1 {
t.Fatalf("want 1 customer row, got %d", len(nodes))
}
}
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")
}
}
// secure is the one state flag with three answers: a producer that reports
// nothing leaves the element unknown rather than known-not-secure, and a
// consumer deciding what a typed value may be written into a record reads the
// difference.
func TestSecureIsUnknownUntilAProducerReportsIt(t *testing.T) {
cases := []struct {
name string
node string
wantReported bool
wantSecure bool
}{
{"reported secure", `{"attributes": {"bounds": "[0,0,10,10]"}, "secure": true}`, true, true},
{"reported not secure", `{"attributes": {"bounds": "[0,0,10,10]"}, "secure": false}`, true, false},
{"never reported", `{"attributes": {"bounds": "[0,0,10,10]"}}`, false, false},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
tree, err := Parse(testCase.node)
if err != nil {
t.Fatalf("Parse: %v", err)
}
element := tree.Elements[0]
if element.SecureReported() != testCase.wantReported {
t.Errorf("SecureReported = %v, want %v", element.SecureReported(), testCase.wantReported)
}
if element.Secure != testCase.wantSecure {
t.Errorf("Secure = %v, want %v", element.Secure, testCase.wantSecure)
}
})
}
}
// A selector reaches the fact by the same route every other boolean state does.
func TestSecureIsSelectable(t *testing.T) {
tree, err := Parse(`{"attributes": {"resource-id": "root", "bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"resource-id": "pwd", "bounds": "[0,0,10,5]"}, "secure": true, "children": []},
{"attributes": {"resource-id": "email", "bounds": "[0,5,10,10]"}, "secure": false, "children": []}
]}`)
if err != nil {
t.Fatalf("Parse: %v", err)
}
element := tree.Find("secure:true")
if element == nil || element.ResourceID != "pwd" {
t.Errorf("secure:true resolved to %+v, want the pwd element", element)
}
}
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
},
{
"attributes": {"identifier": "Feed", "scrollable": "true", "bounds": "[0,120,390,700]"},
"children": [],
"enabled": true
}
]
}`
const classAttrDump = `{
"attributes": {"resource-id": "com.app:id/list", "class": "android.widget.FrameLayout", "bounds": "[0,0,1080,2340]"},
"children": [
{
"attributes": {"resource-id": "com.app:id/row1", "class": "android.widget.Button", "bounds": "[0,0,1080,200]"},
"children": [],
"clickable": true,
"enabled": true
}
]
}`
const webAttrDump = `{
"attributes": {"resource-id": "page", "tag": "html", "bounds": "[0,0,1280,720]"},
"children": [
{
"attributes": {"resource-id": "login_email", "content-desc": "login_email", "tag": "input", "bounds": "[0,0,300,40]"},
"children": [],
"editable": true,
"enabled": true
},
{
"attributes": {"resource-id": "customer_row_a1", "data-testid": "customer-row", "tag": "div", "bounds": "[0,40,300,80]"},
"children": [],
"clickable": true,
"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")
}
}
// className is a spec key no producer writes: android reports the view class,
// ios the element type and the chrome dump el.className, all under `class`. The
// key matched nothing at all here while the web runtime resolved it against the
// live DOM, and the key being accepted meant no unknown-key error said so.
func TestClassNameAliasMatchesClass(t *testing.T) {
tree, _ := Parse(classAttrDump)
el := tree.Find("className:Button")
if el == nil {
t.Fatal("expected className: to match the class attribute via alias")
}
if el.ResourceID != "com.app:id/row1" {
t.Fatalf("got %q, want row1", el.ResourceID)
}
object := tree.FindBySelector(Selector{Filters: []AttrFilter{
{Attr: "className", Value: "Button"},
}})
if object == nil {
t.Fatal("expected the object form to match the class attribute via alias")
}
if object.ResourceID != el.ResourceID {
t.Fatalf("the object form matched %q, want %q", object.ResourceID, el.ResourceID)
}
}
// One fact, four names, and only two of them reached it. Android and the chrome
// dump write the accessible name under content-desc; ios writes it under
// accessibilityText. label and accessibilityLabel aliased onto accessibilityText
// alone, and alias expansion is ONE level, so the hop from there to content-desc
// was never taken: both keys matched nothing on the two platforms that write
// content-desc. ariaLabel and contentDescription aliased onto nothing at all and
// matched nothing anywhere. The web runtime resolves all four against the live
// DOM, so a selector naming a field this way found it on one host and no element
// at all on the other, with no unknown-key error to say so.
func TestAccessibilityLabelAliasesReachContentDesc(t *testing.T) {
tree, _ := Parse(webAttrDump)
for _, key := range []string{"label", "accessibilityLabel", "ariaLabel", "contentDescription"} {
element := tree.Find(key + ":login_email")
if element == nil {
t.Fatalf("expected %s: to match the content-desc attribute via alias", key)
}
if element.ResourceID != "login_email" {
t.Fatalf("%s: matched %q, want login_email", key, element.ResourceID)
}
object := tree.FindBySelector(Selector{Filters: []AttrFilter{
{Attr: key, Value: "login_email"},
}})
if object == nil {
t.Fatalf("expected the object form of %s to match content-desc via alias", key)
}
if object.ResourceID != element.ResourceID {
t.Fatalf("the object form of %s matched %q, want %q",
key, object.ResourceID, element.ResourceID)
}
}
}
// The iOS sidecar writes the same fact under accessibilityText, which the two
// names already reached and have to keep reaching.
func TestAccessibilityLabelAliasesStillReachAccessibilityText(t *testing.T) {
tree, _ := Parse(iosAttrDump)
for _, key := range []string{"label", "accessibilityLabel", "ariaLabel", "contentDescription"} {
if tree.Find(key+":Close") == nil {
t.Fatalf("expected %s: to match the accessibilityText attribute via alias", key)
}
}
}
// Compose for Web writes a test tag as data-testid, the key the web runtime
// resolves testTag and testID against. testTag reached the three identifier
// keys and not that one, and testID aliased onto nothing at all, so a tag the
// web runtime found on both rows named no element here.
func TestTestTagAliasesReachDataTestID(t *testing.T) {
tree, _ := Parse(webAttrDump)
for _, key := range []string{"testTag", "testID"} {
element := tree.Find(key + ":customer-row")
if element == nil {
t.Fatalf("expected %s: to match the data-testid attribute via alias", key)
}
if element.ResourceID != "customer_row_a1" {
t.Fatalf("%s: matched %q, want customer_row_a1", key, element.ResourceID)
}
object := tree.FindBySelector(Selector{Filters: []AttrFilter{
{Attr: key, Value: "customer-row"},
}})
if object == nil {
t.Fatalf("expected the object form of %s to match data-testid via alias", key)
}
if object.ResourceID != element.ResourceID {
t.Fatalf("the object form of %s matched %q, want %q",
key, object.ResourceID, element.ResourceID)
}
}
}
// testTag keeps reaching the identifier keys android and ios write it under.
func TestTestTagStillReachesTheIdentifierKeys(t *testing.T) {
if tree, _ := Parse(androidAttrDump); tree.Find("testTag:row1") == nil {
t.Fatal("expected testTag: to match the resource-id attribute via alias")
}
if tree, _ := Parse(iosAttrDump); tree.Find("testTag:Feed") == nil {
t.Fatal("expected testTag: to match the identifier attribute via alias")
}
}
// bounds is a raw driver attribute rather than a cross-platform key: every dump
// writes the rectangle out as a string and no DOM element carries an attribute
// of that name, so the key resolved here and matched nothing on web on every
// page there is, with no unknown-key error to say so and no mapping to invent
// for it. Off the accepted list the web runtime raises that error, and the
// escape hatch for an attribute the tree carries is what keeps it resolving
// where a producer writes it.
func TestBoundsIsAReachableRawAttributeAndNotAnAcceptedKey(t *testing.T) {
if slices.Contains(SelectorKeys(), "bounds") {
t.Error("bounds names no fact a DOM carries, so it cannot be a cross-platform key")
}
tree, _ := Parse(androidAttrDump)
selector := Selector{Filters: []AttrFilter{{Attr: "bounds", Value: "[0,0,1080,200]"}}}
if unknown := tree.UnknownSelectorKeys(selector); len(unknown) != 0 {
t.Errorf("bounds is an attribute this dump carries, got unknown %v", unknown)
}
node := tree.FindBySelector(selector)
if node == nil {
t.Fatal("expected bounds to match the raw attribute the dump writes")
}
if node.ResourceID != "com.app:id/row1" {
t.Fatalf("bounds matched %q, want row1", node.ResourceID)
}
}
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 TestScrollableMatchesOnIOS(t *testing.T) {
tree, _ := Parse(iosAttrDump)
el := tree.Find("scrollable:true")
if el == nil {
t.Fatal("expected scrollable:true to match the iOS scroll container")
}
if el.Attributes["identifier"] != "Feed" {
t.Fatalf("got %q, want Feed", el.Attributes["identifier"])
}
}
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)
}
}
// 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{
{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)
}
}
// iosFlatDump mirrors the Compose-on-iOS accessibility shape: the testTag node
// surfaces as an empty leaf SIBLING of the container holding the content it
// labels, with equal bounds, instead of as an ancestor.
const iosFlatDump = `{
"attributes": {"bounds": "[0,0][402,874]"},
"children": [
{
"attributes": {"accessibilityText": "Folio", "bounds": "[0,0][402,874]"},
"children": [
{
"attributes": {"resource-id": "LoginScreen", "bounds": "[0,62][402,840]"},
"children": []
},
{
"attributes": {"bounds": "[0,62][402,840]"},
"children": [
{
"attributes": {"accessibilityText": "EMAIL", "bounds": "[20,106][60,120]"},
"children": []
},
{
"attributes": {"resource-id": "LoginEmail", "accessibilityText": "Email", "bounds": "[34,125][368,173]"},
"children": [
{"attributes": {"bounds": "[34,125][368,173]"}, "children": []}
]
},
{
"attributes": {"resource-id": "LoginPassword", "accessibilityText": "Password", "bounds": "[34,205][368,253]"},
"children": []
},
{
"attributes": {"text": "Sign in", "bounds": "[20,297][382,345]"},
"children": []
},
{
"attributes": {"resource-id": "LoginSubmit", "accessibilityText": "Sign in", "bounds": "[20,297][382,345]"},
"children": [
{"attributes": {"text": "Sign in", "resource-id": "SubmitLabel", "bounds": "[168,312][233,330]"}, "children": []}
]
}
]
}
]
},
{
"attributes": {"bounds": "[0,0][402,54]"},
"children": [
{
"attributes": {"resource-id": "StatusClock", "accessibilityText": "3:24 PM", "bounds": "[55,22][92,42]"},
"children": []
}
]
}
]
}`
func TestIOSFlatSelectorPathFallsBackToBounds(t *testing.T) {
tree, err := Parse(iosFlatDump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
path := []Selector{
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginEmail"}}},
}
node := tree.FindBySelectorPath(path)
if node == nil {
t.Fatal("expected LoginEmail via bounds containment under leaf LoginScreen")
}
if node.ResourceID != "LoginEmail" {
t.Fatalf("got %q, want LoginEmail", node.ResourceID)
}
}
func TestIOSFlatStringPathFallsBackToBounds(t *testing.T) {
tree, _ := Parse(iosFlatDump)
element := tree.Find("id:LoginScreen > id:LoginSubmit")
if element == nil {
t.Fatal("expected LoginSubmit via bounds containment under leaf LoginScreen")
}
if element.ResourceID != "LoginSubmit" {
t.Fatalf("got %q, want LoginSubmit", element.ResourceID)
}
}
func TestIOSFlatScopedNodeFindFallsBackToBounds(t *testing.T) {
tree, _ := Parse(iosFlatDump)
screen := tree.FindNode("id:LoginScreen")
if screen == nil {
t.Fatal("expected LoginScreen node")
}
node := screen.Find("id:LoginPassword")
if node == nil {
t.Fatal("expected LoginPassword via bounds containment")
}
if node.ResourceID != "LoginPassword" {
t.Fatalf("got %q, want LoginPassword", node.ResourceID)
}
}
func TestIOSFlatFindAllBySelectorPathReturnsEachField(t *testing.T) {
tree, _ := Parse(iosFlatDump)
path := []Selector{
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
{Filters: []AttrFilter{{Attr: "label", Value: "word"}}},
}
// label "word" substring-matches "Password" only.
nodes := tree.FindAllBySelectorPath(path)
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
if nodes[0].ResourceID != "LoginPassword" {
t.Fatalf("got %q, want LoginPassword", nodes[0].ResourceID)
}
}
func TestIOSFlatSpatialScopeExcludesOutsideBounds(t *testing.T) {
tree, _ := Parse(iosFlatDump)
// StatusClock sits in the status bar above LoginScreen's bounds; the
// spatial fallback must not leak it into the screen's scope.
if tree.Find("id:LoginScreen > id:StatusClock") != nil {
t.Fatal("StatusClock is outside LoginScreen bounds, expected nil")
}
}
// spatialSpecificityDump scopes a leaf testTag (iOS-flat pattern) over a
// screen where both a screen-sized container and the small element inside it
// match the same attribute, plus two equal-bounds siblings for tie ordering.
const spatialSpecificityDump = `{
"attributes": {"bounds": "[0,0][402,874]"},
"children": [
{
"attributes": {"resource-id": "FormScreen", "bounds": "[0,62][402,840]"},
"children": []
},
{
"attributes": {"resource-id": "FieldContainer", "accessibilityText": "Amount", "bounds": "[0,62][402,840]"},
"children": [
{
"attributes": {"resource-id": "AmountField", "accessibilityText": "Amount", "bounds": "[34,125][368,173]"},
"children": []
},
{
"attributes": {"resource-id": "FirstTab", "accessibilityText": "Tab", "bounds": "[20,297][382,345]"},
"children": []
},
{
"attributes": {"resource-id": "SecondTab", "accessibilityText": "Tab", "bounds": "[20,297][382,345]"},
"children": []
}
]
}
]
}`
func TestSpatialFallbackPrefersSmallestContainingMatch(t *testing.T) {
tree, err := Parse(spatialSpecificityDump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
screen := tree.FindNode("id:FormScreen")
if screen == nil {
t.Fatal("expected FormScreen node")
}
// Both FieldContainer (screen-sized, earlier in pre-order) and
// AmountField (small) match; the most specific match must win.
node := screen.Find("desc:Amount")
if node == nil {
t.Fatal("expected a spatial-fallback match")
}
if node.ResourceID != "AmountField" {
t.Fatalf("expected the smallest containing match AmountField, got id=%q", node.ResourceID)
}
}
func TestSpatialFallbackEqualAreaKeepsPreOrder(t *testing.T) {
tree, err := Parse(spatialSpecificityDump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
screen := tree.FindNode("id:FormScreen")
if screen == nil {
t.Fatal("expected FormScreen node")
}
node := screen.Find("desc:Tab")
if node == nil {
t.Fatal("expected a spatial-fallback match")
}
if node.ResourceID != "FirstTab" {
t.Fatalf("equal-area matches must keep pre-order, got id=%q", node.ResourceID)
}
}
func TestIOSFlatStructuralChildStillPreferred(t *testing.T) {
tree, _ := Parse(iosFlatDump)
submit := tree.FindNode("id:LoginSubmit")
if submit == nil {
t.Fatal("expected LoginSubmit node")
}
// "Sign in" exists as the structural child of LoginSubmit and as an
// equal-bounds sibling decoration; the structural child must win.
node := submit.Find("text:Sign in")
if node == nil {
t.Fatal("expected structural child match")
}
if node.ResourceID != "SubmitLabel" {
t.Fatalf("expected the structural child SubmitLabel, got id=%q", node.ResourceID)
}
}
// Bug class: malformed device output must surface as an error, not a nil tree
// the callers dereference. A blank string is the only documented benign input.
func TestParseInvalidJSON(t *testing.T) {
cases := []struct {
name string
input string
wantErr bool
}{
{"truncated object", `{"attributes":`, true},
{"trailing garbage", `{"attributes":{}} oops`, true},
{"not an object", `[1,2,3]`, true},
{"bare garbage", `not json at all`, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := Parse(tc.input)
if (err != nil) != tc.wantErr {
t.Fatalf("Parse(%q) err = %v, wantErr %v", tc.input, err, tc.wantErr)
}
})
}
}
// Bug class: a node carrying a bounds value the device emitted in an
// unrecognized shape must degrade to a zero rectangle without losing the rest
// of the element. parseBounds' error is intentionally swallowed in
// elementFromNode, so a regression that aborts the whole parse, or that
// corrupts neighboring fields, would be caught here.
func TestParseMalformedBoundsKeepsElementIntact(t *testing.T) {
cases := []string{
"[1,2,3]", // too few coordinates
"garbage", // not a bounds string at all
"[a,b,c,d]", // non-numeric
"", // empty
}
for _, b := range cases {
t.Run(b, func(t *testing.T) {
input := `{"attributes":{"resource-id":"app:id/x","text":"keep","bounds":"` + b + `"},"children":[]}`
tree, err := Parse(input)
if err != nil {
t.Fatalf("Parse: %v", err)
}
el := tree.Find("id:x")
if el == nil {
t.Fatal("element dropped when bounds was malformed")
}
if el.Text != "keep" {
t.Errorf("neighboring field corrupted: text=%q", el.Text)
}
if el.Bounds != (Bounds{}) {
t.Errorf("malformed bounds must yield zero rectangle, got %+v", el.Bounds)
}
})
}
}
// Bug class: parseBounds must reject inputs that are neither [L,T,R,B] nor
// [x1,y1][x2,y2] rather than returning a partially-filled rectangle.
func TestParseBoundsRejectsBadInput(t *testing.T) {
for _, in := range []string{"[1,2,3]", "[1,2,3,4,5]", "1,2,3,4", "[]", "garbage"} {
if _, err := parseBounds(in); err == nil {
t.Errorf("parseBounds(%q) = nil error, want error", in)
}
}
}
// Bug class: a NavHost cross-fade carries two route-level *Screen ids at once;
// both the runner's re-fetch guard and the LLM's candidate enumeration depend on
// spotting it, and neither must flag a settled single-screen tree.
func TestTreeTransitional(t *testing.T) {
multi, err := Parse(`{"attributes":{"resource-id":"root"},"children":[
{"attributes":{"resource-id":"AddAccountScreen"},"children":[]},
{"attributes":{"resource-id":"HomeScreen"},"children":[]}
]}`)
if err != nil {
t.Fatal(err)
}
if !multi.Transitional() {
t.Error("expected multi-screen tree to be flagged as transitional")
}
single, err := Parse(`{"attributes":{"resource-id":"HomeScreen"},"children":[]}`)
if err != nil {
t.Fatal(err)
}
if single.Transitional() {
t.Error("single-screen tree must not be flagged as transitional")
}
var nilTree *Tree
if nilTree.Transitional() {
t.Error("nil tree must not be flagged as transitional")
}
}
// A key means the same thing whichever form the author writes it in. The object
// form used to fall through to the raw attribute map, which carries neither
// "id" nor "desc" on any platform.
func TestObjectSelectorIDMatchesTheSameElementsAsTheStringForm(t *testing.T) {
tree, _ := Parse(sampleDump)
sel := Selector{Filters: []AttrFilter{{Attr: "id", Value: "row"}}}
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")))
}
if len(object) != 2 {
t.Fatalf("want 2 rows, got %d", len(object))
}
}
func TestObjectSelectorDescMatchesTheSameElementsAsTheStringForm(t *testing.T) {
tree, _ := Parse(sampleDump)
sel := Selector{Filters: []AttrFilter{{Attr: "desc", Value: "row"}}}
if len(tree.FindAllBySelector(sel)) != len(tree.FindAll("desc:row")) {
t.Fatal("object and string form disagree on desc")
}
}
func TestUnknownSelectorKeyIsReported(t *testing.T) {
tree, _ := Parse(sampleDump)
sel := Selector{Filters: []AttrFilter{{Attr: "descripton", Value: "row"}}}
unknown := tree.UnknownSelectorKeys(sel)
if len(unknown) != 1 || unknown[0] != "descripton" {
t.Fatalf("got %v, want [descripton]", unknown)
}
message := UnknownSelectorKeyMessage(unknown)
if !strings.Contains(message, `"descripton"`) || !strings.Contains(message, "resource-id") {
t.Fatalf("message names neither the key nor the accepted list: %s", message)
}
}
func TestAcceptedSelectorKeyAbsentFromTheScreenIsNotUnknown(t *testing.T) {
tree, _ := Parse(sampleDump)
sel := Selector{Filters: []AttrFilter{{Attr: "title", Value: "Settings"}}}
if unknown := tree.UnknownSelectorKeys(sel); len(unknown) != 0 {
t.Fatalf("a platform-specific key must stay silent, got %v", unknown)
}
}
// Raw driver attributes stay reachable: a key some element carries can match,
// whether or not this package enumerates it.
func TestRawDriverAttributeIsNotUnknown(t *testing.T) {
input := `{
"attributes": {"resource-id": "root", "important-for-accessibility": "true"},
"children": []
}`
tree, _ := Parse(input)
sel := Selector{Filters: []AttrFilter{{Attr: "important-for-accessibility", Value: "true"}}}
if unknown := tree.UnknownSelectorKeys(sel); len(unknown) != 0 {
t.Fatalf("got %v, want none", unknown)
}
}
// TestStoredTreeRebuildsRootAndResolvesSelectors is the offline half of every
// trace: a tree that only survives as a flat pre-order array resolves nothing,
// because every lookup walks Root.
func TestStoredTreeRebuildsRootAndResolvesSelectors(t *testing.T) {
tree, err := Parse(sampleDump)
if err != nil {
t.Fatal(err)
}
stored, err := json.Marshal(tree)
if err != nil {
t.Fatal(err)
}
var decoded Tree
if err := json.Unmarshal(stored, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Root == nil {
t.Fatal("Root not rebuilt from the stored tree")
}
if len(decoded.Elements) != len(tree.Elements) {
t.Fatalf(
"elements = %d, want %d",
len(decoded.Elements),
len(tree.Elements),
)
}
for index, element := range decoded.Elements {
if element.ResourceID != tree.Elements[index].ResourceID {
t.Fatalf(
"element %d = %q, want %q",
index,
element.ResourceID,
tree.Elements[index].ResourceID,
)
}
}
online := tree.Find("id:app:id/title")
offline := decoded.Find("id:app:id/title")
if online == nil {
t.Fatal("the selector does not resolve online; the fixture is wrong")
}
if offline == nil || offline.Text != online.Text {
t.Fatalf(
"selector resolves online to %+v, offline to %+v",
online,
offline,
)
}
if decoded.Find("id:app:id/title") != decoded.Elements[1] {
t.Error(
"the rebuilt nodes and the decoded element list are different pointers",
)
}
childCount := 0
var walk func(node *Node)
walk = func(node *Node) {
childCount++
for _, child := range node.Children {
walk(child)
}
}
walk(decoded.Root)
if childCount != len(decoded.Elements) {
t.Errorf(
"the rebuilt tree holds %d nodes, the element list %d",
childCount,
len(decoded.Elements),
)
}
}
// TestStoredTreeWithoutDepthsKeepsTheOldShape: traces written before the
// depths field must still load, and must say "no structure" rather than
// inventing one.
func TestStoredTreeWithoutDepthsKeepsTheOldShape(t *testing.T) {
var decoded Tree
if err := json.Unmarshal([]byte(`{"elements":[{"resourceId":"root"},{"resourceId":"child"}]}`), &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.Elements) != 2 {
t.Fatalf("elements = %d, want 2", len(decoded.Elements))
}
if decoded.Root != nil {
t.Errorf(
"Root = %+v, want nil for a stored tree that carries no depths",
decoded.Root,
)
}
}
func TestStoredTreeRejectsDepthsItCannotRebuild(t *testing.T) {
for name, stored := range map[string]string{
"count mismatch": `{"elements":[{"resourceId":"a"},{"resourceId":"b"}],"depths":[0]}`,
"rootless": `{"elements":[{"resourceId":"a"}],"depths":[1]}`,
"second root": `{"elements":[{"resourceId":"a"},{"resourceId":"b"}],"depths":[0,0]}`,
"skipped level": `{"elements":[{"resourceId":"a"},{"resourceId":"b"}],"depths":[0,2]}`,
} {
var decoded Tree
if err := json.Unmarshal([]byte(stored), &decoded); err != nil {
t.Fatalf("%s: %v", name, err)
}
if decoded.Root != nil {
t.Errorf("%s: Root = %+v, want nil", name, decoded.Root)
}
}
}
// TestTreeStoresNoDepthsForAnUnwalkableRoot keeps the stored shape honest:
// a hand-built Tree whose Root and Elements disagree must not claim a
// structure that would rebuild into a different tree.
func TestTreeStoresNoDepthsForAnUnwalkableRoot(t *testing.T) {
tree := &Tree{
Root: &Node{Element: Element{ResourceID: "root"}},
Elements: []*Element{{ResourceID: "root"}, {ResourceID: "orphan"}},
}
stored, err := json.Marshal(tree)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(stored), "depths") {
t.Errorf("stored a shape that cannot be rebuilt: %s", stored)
}
}
// A producer that puts a string where a flag belongs must cost that flag and
// nothing else. Failing the document instead blanks the whole tree, and every
// extractor then reads a screen with no elements on it.
func TestParseUnreadableFlagKeepsTheRestOfTheTree(t *testing.T) {
input := `{"attributes":{"resource-id":"app:id/root","bounds":"[0,0,400,800]"},"children":[
{"attributes":{"resource-id":"app:id/tabs","text":"keep"},"selected":"active","children":[]},
{"attributes":{"resource-id":"app:id/leaf"},"clickable":true,"children":[]}
]}`
tree, err := Parse(input)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if len(tree.Elements) != 3 {
t.Fatalf("elements = %d, want 3: one unreadable flag must not cost the tree", len(tree.Elements))
}
tabs := tree.Find("id:tabs")
if tabs == nil {
t.Fatal("the element carrying the unreadable flag was dropped")
}
if tabs.Text != "keep" {
t.Errorf("neighbouring field corrupted: text=%q", tabs.Text)
}
if tabs.Selected {
t.Error("selected must stay unset when the value the producer sent is not a boolean")
}
leaf := tree.Find("id:leaf")
if leaf == nil || !leaf.Clickable {
t.Errorf("a readable flag elsewhere in the tree must survive, got %+v", leaf)
}
if tree.UnreadableFlags != 1 {
t.Errorf("UnreadableFlags = %d, want 1: a dropped flag has to be countable", tree.UnreadableFlags)
}
stored, err := json.Marshal(tree)
if err != nil {
t.Fatal(err)
}
var decoded Tree
if err := json.Unmarshal(stored, &decoded); err != nil {
t.Fatal(err)
}
if decoded.UnreadableFlags != 1 {
t.Errorf("stored UnreadableFlags = %d, want 1: the trace has to carry it", decoded.UnreadableFlags)
}
}
// selectorFormsDump carries one node per id shape a real dump produces, plus
// nodes carrying a description in the ", " form the desc rule knows about and a
// text the text rule matches on a substring.
const selectorFormsDump = `{
"attributes": {"resource-id": "root", "bounds": "[0,0,400,800]"},
"children": [
{"attributes": {"resource-id": "BareThing", "bounds": "[0,0,100,50]"}, "children": []},
{"attributes": {"resource-id": "com.example.app:id/AndroidThing", "bounds": "[0,50,100,100]"},
"children": []},
{"attributes": {"accessibilityIdentifier": "IosThing", "bounds": "[0,100,100,150]"},
"children": []},
{"attributes": {"resource-id": "Described", "content-desc": "Save, button", "bounds": "[0,150,100,200]"},
"children": []},
{"attributes": {"resource-id": "Labelled", "text": "Total balance", "bounds": "[0,200,100,250]"},
"children": []}
]
}`
// TestSelectorFormsResolveTheSameElement holds the two selector forms a spec can
// write to ONE rule per key. A spec reaches these through state.ax.find: a
// string goes to FindNode, an object to FindBySelector, and the two ran
// different matchers. `id` has a kind arm that knows an Android resource id is
// package-qualified (com.example.app:id/Thing) and that a spec names the bare
// tail; the object form had no such arm and looked for a literal `id` attribute
// no producer writes, so {id: "Thing"} silently matched nothing on every
// platform while "id:Thing" matched. `desc` and `descPrefix` had the same
// split. A selector that resolves nothing makes every property over it
// vacuously true, which is the failure that reports a green run while checking
// nothing.
func TestSelectorFormsResolveTheSameElement(t *testing.T) {
tree, err := Parse(selectorFormsDump)
if err != nil {
t.Fatal(err)
}
for _, test := range []struct {
key string
value string
want string
}{
{"id", "BareThing", "BareThing"},
// A spec names the tail; an Android dump carries the package prefix.
{"id", "AndroidThing", "com.example.app:id/AndroidThing"},
{"id", "com.example.app:id/AndroidThing", "com.example.app:id/AndroidThing"},
{"id", "IosThing", "IosThing"},
{"desc", "Save, button", "Described"},
// The ", " form an accessibility label takes when a role is appended.
{"desc", "Save", "Described"},
{"descPrefix", "Sav", "Described"},
{"text", "Total", "Labelled"},
{"resource-id", "BareThing", "BareThing"},
{"testTag", "IosThing", "IosThing"},
} {
t.Run(test.key+":"+test.value, func(t *testing.T) {
stringForm := test.key + ":" + test.value
fromString := tree.FindNode(stringForm)
if fromString == nil {
t.Fatalf("the string form %q resolved nothing", stringForm)
}
if fromString.ResourceID != test.want {
t.Fatalf("the string form resolved %q, want %q", fromString.ResourceID, test.want)
}
fromObject := tree.Root.FindBySelector(
Selector{Filters: []AttrFilter{{Attr: test.key, Value: test.value}}},
)
if fromObject == nil {
t.Fatalf(
"the object form {%s: %q} resolved nothing while %q resolved %q",
test.key, test.value, stringForm, fromString.ResourceID,
)
}
if fromObject != fromString {
t.Errorf(
"one selector, two answers: {%s: %q} resolved %q and %q resolved %q",
test.key, test.value, fromObject.ResourceID, stringForm, fromString.ResourceID,
)
}
})
}
}
// customElementDump is the shape a page built from custom elements reports: a
// container's tag name contains the tag name of what it holds, so "todo-list"
// carries "li" and "todo-app" carries "a".
const customElementDump = `{
"attributes": {"tag": "todo-app", "resource-id": "app", "bounds": "[0,0,800,600]"},
"children": [
{
"attributes": {"tag": "todo-list", "resource-id": "list", "bounds": "[0,0,800,400]"},
"children": [
{"attributes": {"tag": "li", "resource-id": "todo_1", "bounds": "[0,0,800,50]"}, "children": []},
{"attributes": {"tag": "li", "resource-id": "todo_2", "bounds": "[0,50,800,100]"}, "children": []}
]
},
{
"attributes": {"tag": "a", "resource-id": "filter_all", "bounds": "[0,400,800,450]"},
"children": []
}
]
}`
func TestTagNamesTheWholeTagNotASubstringOfIt(t *testing.T) {
tree, _ := Parse(customElementDump)
for _, test := range []struct {
value string
want []string
}{
{"li", []string{"todo_1", "todo_2"}},
{"a", []string{"filter_all"}},
} {
t.Run(test.value, func(t *testing.T) {
sel := Selector{Filters: []AttrFilter{{Attr: "tag", Value: test.value}}}
if got := resourceIDsOf(tree.FindAllBySelector(sel)); !slices.Equal(got, test.want) {
t.Errorf("{tag: %q} matched %v, want %v", test.value, got, test.want)
}
stringForm := "tag:" + test.value
if got := resourceIDsOf(tree.FindAllNodes(stringForm)); !slices.Equal(got, test.want) {
t.Errorf("%q matched %v, want %v", stringForm, got, test.want)
}
})
}
}
func TestTagMatchesTheElementItNames(t *testing.T) {
tree, _ := Parse(customElementDump)
for _, test := range []struct {
value string
want []string
}{
{"todo-app", []string{"app"}},
{"todo-list", []string{"list"}},
} {
t.Run(test.value, func(t *testing.T) {
sel := Selector{Filters: []AttrFilter{{Attr: "tag", Value: test.value}}}
if got := resourceIDsOf(tree.FindAllBySelector(sel)); !slices.Equal(got, test.want) {
t.Errorf("{tag: %q} matched %v, want %v", test.value, got, test.want)
}
stringForm := "tag:" + test.value
if got := resourceIDsOf(tree.FindAllNodes(stringForm)); !slices.Equal(got, test.want) {
t.Errorf("%q matched %v, want %v", stringForm, got, test.want)
}
})
}
}
func TestScreenNameNamesTheRouteTheTreeShows(t *testing.T) {
cases := []struct {
name string
tree string
want string
}{
{
"driver-set screen",
`{"attributes": {"bounds": "[0,0,10,10]", "sanderling-screen": "/ledger"}, "children": []}`,
"/ledger",
},
{
"route marker",
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,10]"}, "children": []}
]}`,
"HomeScreen",
},
{
"cross-fade names no screen",
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": [
{"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,10]"}, "children": []},
{"attributes": {"resource-id": "LedgerScreen", "bounds": "[0,0,10,10]"}, "children": []}
]}`,
"",
},
{
"no marker at all",
`{"attributes": {"bounds": "[0,0,10,10]"}, "children": []}`,
"",
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
tree, err := Parse(testCase.tree)
if err != nil {
t.Fatalf("Parse: %v", err)
}
if got := tree.ScreenName(); got != testCase.want {
t.Errorf("ScreenName = %q, want %q", got, testCase.want)
}
})
}
}