From d115cdfd974600a87498000da1e68b5ecf2e3ab7 Mon Sep 17 00:00:00 2001 From: PJ Date: Sun, 26 Apr 2026 14:13:46 +0700 Subject: [PATCH] feat(hierarchy): testTag alias resolves to resource-id and accessibilityIdentifier Compose's testTag surfaces as resource-id on Android and as accessibilityIdentifier on iOS. Selectors written as { testTag: "Foo" } now match either, so Sanderling specs can use the same tag on both platforms. Also rounds out the iOS identifier aliases so resource-id / identifier / accessibilityIdentifier all resolve to one another. --- internal/hierarchy/hierarchy.go | 12 ++++--- internal/hierarchy/hierarchy_test.go | 50 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) 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")