diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 78ee995..37f4abc 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -18,7 +18,9 @@ // // Cross-platform aliases are expanded automatically: "label" / "accessibilityLabel" // resolve to accessibilityText; "content-desc" also checks accessibilityText and -// vice-versa; "identifier" / "accessibilityIdentifier" resolve to resource-id. +// vice-versa; "identifier" / "accessibilityIdentifier" / "testTag" resolve to +// resource-id (and to each other) so a Compose testTag matches whether the +// underlying platform exposes it as resource-id (Android) or accessibilityIdentifier (iOS). package hierarchy import ( @@ -113,10 +115,12 @@ var attributeAliases = map[string][]string{ // accessibilityText is the canonical key; also check content-desc for Android/web "accessibilityText": {"content-desc"}, // resource-id canonical key; also check identifier (iOS AXElement raw field) - "resource-id": {"identifier"}, + "resource-id": {"identifier", "accessibilityIdentifier"}, // iOS identifier names - "identifier": {"resource-id"}, - "accessibilityIdentifier": {"resource-id"}, + "identifier": {"resource-id", "accessibilityIdentifier"}, + "accessibilityIdentifier": {"resource-id", "identifier"}, + // Compose testTag surfaces as resource-id on Android, accessibilityIdentifier on iOS + "testTag": {"resource-id", "identifier", "accessibilityIdentifier"}, // iOS AXElement raw name for hintText "placeholderValue": {"hintText"}, // iOS AXElement raw name for class diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index ae896d1..667dab1 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -464,6 +464,56 @@ func TestNodeFindScopedSearch(t *testing.T) { } } +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 TestNodeFindDoesNotReturnSiblings(t *testing.T) { tree, _ := Parse(pathDump) a2Node := tree.FindNode("id:A2")