fix(hierarchy): reach the accessible label through every name for it

label and accessibilityLabel aliased onto accessibilityText alone, which
only the ios sidecar writes, and alias expansion is ONE level: the hop
from accessibilityText to content-desc was never taken, so both keys
matched nothing on android and on the chrome dump, which write the fact
under 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. The keys are accepted, so no unknown-key error fires, and a
property over the element that was never found passes having checked
nothing.

Each name lists both keys rather than chaining through accessibilityText:
transitive expansion would silently widen every existing key at once.
This commit is contained in:
pj committed 2026-08-19 10:06:56 +05:30
1 parent 5a7c223464
commit ae629d9d7d
2 files changed
+69 -3

No files matched your search

+8 -3
View File
@@ -259,9 +259,14 @@ type AttrFilter struct {
var attributeAliases = map[string][]string{ var attributeAliases = map[string][]string{
// Android XML legacy name; web driver uses content-desc; the sidecar normalises to accessibilityText // Android XML legacy name; web driver uses content-desc; the sidecar normalises to accessibilityText
"content-desc": {"accessibilityText"}, "content-desc": {"accessibilityText"},
// iOS AXElement / UIKit names // Every other name for the accessible label. Alias expansion is one level,
"label": {"accessibilityText"}, // so each name lists both keys a producer writes the fact under rather than
"accessibilityLabel": {"accessibilityText"}, // hopping through accessibilityText: android and the chrome dump write
// content-desc, the ios sidecar writes accessibilityText.
"label": {"accessibilityText", "content-desc"},
"accessibilityLabel": {"accessibilityText", "content-desc"},
"ariaLabel": {"accessibilityText", "content-desc"},
"contentDescription": {"accessibilityText", "content-desc"},
// accessibilityText is the canonical key; also check content-desc for Android/web // accessibilityText is the canonical key; also check content-desc for Android/web
"accessibilityText": {"content-desc"}, "accessibilityText": {"content-desc"},
// resource-id canonical key; also check identifier (iOS AXElement raw field) // resource-id canonical key; also check identifier (iOS AXElement raw field)
+61
View File
@@ -552,6 +552,24 @@ const classAttrDump = `{
] ]
}` }`
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) { func TestRawResourceIDSubstringMatch(t *testing.T) {
tree, _ := Parse(androidAttrDump) tree, _ := Parse(androidAttrDump)
el := tree.Find("resource-id:row1") el := tree.Find("resource-id:row1")
@@ -592,6 +610,49 @@ func TestClassNameAliasMatchesClass(t *testing.T) {
} }
} }
// 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)
}
}
}
func TestContentDescAliasOnIOS(t *testing.T) { func TestContentDescAliasOnIOS(t *testing.T) {
tree, _ := Parse(iosAttrDump) tree, _ := Parse(iosAttrDump)
el := tree.Find("content-desc:Close") el := tree.Find("content-desc:Close")