diff --git a/docs/manual/spec-language.md b/docs/manual/spec-language.md index 5dcb7cf..1fd3779 100644 --- a/docs/manual/spec-language.md +++ b/docs/manual/spec-language.md @@ -60,6 +60,7 @@ Selectors are passed to `ax.find()`, `ax.findAll()`, and element-scoped `.find() | `text:` | Substring match on text content, innermost match only | | `desc:` | Exact match on accessibility description; also matches when description starts with `, ` (iOS merged labels) | | `descPrefix:` | Starts-with match on accessibility description | +| `tag:` | Exact match on the element's tag name (web) | | `:` | Substring match on any raw attribute by name | Boolean attributes (`"true"` / `"false"`) use exact match rather than substring. @@ -75,7 +76,7 @@ s.ax.find({ accessibilityText: "LoginScreen" }) s.ax.find({ testTag: "AccountCard", clickable: true }) ``` -Every key-value pair must match. A key means the same thing here as in the string form: `id`, `desc`, `idPrefix` and `descPrefix` keep their matching rules, and every other key is an attribute name, with substring and boolean rules per attribute. +Every key-value pair must match. A key means the same thing here as in the string form: `id`, `desc`, `idPrefix`, `descPrefix` and `tag` keep their matching rules, and every other key is an attribute name, with substring and boolean rules per attribute. Known attribute names are typed; you get autocomplete on `testTag`, `text`, `content-desc`, the boolean states (`clickable`, `enabled`, `focused`, `checked`, `selected`), and the cross-platform aliases (`identifier`, `accessibilityIdentifier`, `accessibilityText`, `accessibilityLabel`, `label`, `resource-id`, `class`, `elementType`, `package`, `placeholderValue`, `hintText`). Boolean state attributes accept a native `true` / `false`. Other attribute keys still type-check as a string-valued fallback so raw driver attributes remain reachable. diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 09c735c..e379070 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -10,6 +10,7 @@ // text: - substring on text attribute, innermost match only // desc: - substring on content-desc / accessibilityText // descPrefix: - starts-with on content-desc / accessibilityText +// tag: - exact match on the element's tag name (web) // // Object selectors (multi-attribute AND, element-scoped or global): // { attr: value, ... } - all key/value pairs must match, each key resolved by @@ -394,6 +395,13 @@ func matchSelectorKind(element *Element, kind, value string) (bool, bool) { strings.HasPrefix(element.Description, value+", "), true case "descPrefix": return strings.HasPrefix(element.Description, value), true + case "tag": + // Both DOM resolvers compile this to a CSS type selector, which is the + // whole tag name. A substring rule here made {tag: "li"} name + // and {tag: "a"} name , so a selector meant for a + // row resolved to the container holding it. + tag, ok := element.Attributes["tag"] + return ok && tag == value, true default: return false, false } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 8a2632c..360be7f 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -1551,3 +1551,67 @@ func TestSelectorFormsResolveTheSameElement(t *testing.T) { }) } } + +// 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) + } + }) + } +}