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", }, { "route marker repeated by a nested node", `{"attributes": {"bounds": "[0,0,10,10]"}, "children": [ {"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,10]"}, "children": [ {"attributes": {"resource-id": "HomeScreen", "bounds": "[0,0,10,5]"}, "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) } }) } }