mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
both sides independently fixed the same three bugs, so each one had to pick a winner rather than keep both implementations. extractor encoding: master's recordableValue in worker.go wins over ours in marshal.go, since master's is pinned by extractor_encoding_test.go and ours had no tests. our error semantics stay: encodeExtractorValue still returns an error instead of nil, so an extractor cannot vanish from the trace silently. apply errors: only the residual generic branch takes master's unconfirmed copy, where the device may have committed the action before the call failed. the finer branches that know nothing was dispatched keep lastAction = nil, and our actionSkipReason taxonomy stays alongside master's held/skippedVerification. selector matching: our matchAttr with matchSelectorKind wins over master's match, since ours also handles idPrefix. matchSelector now calls it, which git did not flag as a conflict and left calling a function our side had deleted. the ltl doc comment takes master's correction: an unbounded eventually that never fires IS violated at run end.
1554 lines
49 KiB
Go
1554 lines
49 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")
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
]
|
|
}`
|
|
|
|
func TestRawResourceIDSubstringMatch(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("resource-id:row1")
|
|
if el == nil {
|
|
t.Fatal("expected resource-id: to match via substring")
|
|
}
|
|
}
|
|
|
|
func TestLabelAliasMatchesAccessibilityText(t *testing.T) {
|
|
tree, _ := Parse(iosAttrDump)
|
|
el := tree.Find("label:Close")
|
|
if el == nil {
|
|
t.Fatal("expected label: to match accessibilityText via alias")
|
|
}
|
|
}
|
|
|
|
func TestContentDescAliasOnIOS(t *testing.T) {
|
|
tree, _ := Parse(iosAttrDump)
|
|
el := tree.Find("content-desc:Close")
|
|
if el == nil {
|
|
t.Fatal("expected content-desc: to match accessibilityText via alias on iOS")
|
|
}
|
|
}
|
|
|
|
func TestScrollableTrueMatches(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("scrollable:true")
|
|
if el == nil {
|
|
t.Fatal("expected scrollable:true to match")
|
|
}
|
|
if el.ResourceID != "com.app:id/row1" {
|
|
t.Fatalf("got %q, want row1", el.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestScrollableFalseMatchesSecondRow(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("scrollable:false")
|
|
if el == nil {
|
|
t.Fatal("expected scrollable:false to match row2")
|
|
}
|
|
if el.ResourceID != "com.app:id/row2" {
|
|
t.Fatalf("got %q, want row2", el.ResourceID)
|
|
}
|
|
}
|
|
|
|
func TestTitleMatchesIOSElement(t *testing.T) {
|
|
tree, _ := Parse(iosAttrDump)
|
|
el := tree.Find("title:Settings")
|
|
if el == nil {
|
|
t.Fatal("expected title:Settings to match iOS element")
|
|
}
|
|
}
|
|
|
|
func TestTitleReturnsNilForAndroid(t *testing.T) {
|
|
tree, _ := Parse(androidAttrDump)
|
|
el := tree.Find("title:Settings")
|
|
if el != nil {
|
|
t.Fatal("expected title:Settings to return nil for Android element (graceful ignore)")
|
|
}
|
|
}
|
|
|
|
func 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,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|