feat(xcui): add a11y-tree parse types, find/walk, and parser fuzz target

This commit is contained in:
Charles Wiltgen
2026-05-31 11:05:55 -07:00
parent 5b6be2e3ab
commit aefd02409b
2 changed files with 122 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
package main
import "encoding/json"
// Frame is the numeric rect AXe emits alongside the string AXFrame.
type Frame struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Width float64 `json:"width"`
Height float64 `json:"height"`
}
// AXElement mirrors one node of `axe describe-ui` output. Fields that AXe
// emits as JSON null are pointers so absence is distinguishable from "".
type AXElement struct {
AXUniqueID *string `json:"AXUniqueId"`
AXLabel *string `json:"AXLabel"`
AXValue *string `json:"AXValue"`
Title *string `json:"title"`
Help *string `json:"help"`
Subrole *string `json:"subrole"`
Role string `json:"role"`
RoleDescription string `json:"role_description"`
Type string `json:"type"`
AXFrame string `json:"AXFrame"`
Frame Frame `json:"frame"`
Enabled bool `json:"enabled"`
PID int `json:"pid"`
Children []AXElement `json:"children"`
}
func parseDescribeUI(data []byte) ([]AXElement, error) {
var roots []AXElement
if err := json.Unmarshal(data, &roots); err != nil {
return nil, err
}
return roots, nil
}
// walk visits every element depth-first, roots first.
func walk(roots []AXElement, visit func(AXElement)) {
for _, el := range roots {
visit(el)
walk(el.Children, visit)
}
}
// findByID returns every element whose AXUniqueId equals id. More than one
// match means the identifier isn't unique (the --single assertion catches it).
func findByID(roots []AXElement, id string) []AXElement {
var out []AXElement
walk(roots, func(el AXElement) {
if el.AXUniqueID != nil && *el.AXUniqueID == id {
out = append(out, el)
}
})
return out
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
+57
View File
@@ -7,3 +7,60 @@ func TestVersionConstSet(t *testing.T) {
t.Fatal("version const must be set")
}
}
const sampleTree = `[
{
"AXUniqueId": null, "AXLabel": "App", "AXValue": null,
"role": "AXApplication", "type": "Application", "enabled": true,
"frame": {"x":0,"y":0,"width":402,"height":874},
"children": [
{
"AXUniqueId": "artist.hero", "AXLabel": "Artwork for The Chemical Brothers",
"AXValue": null, "role": "AXImage", "type": "Image", "enabled": true,
"frame": {"x":0,"y":0,"width":402,"height":402}, "children": []
},
{
"AXUniqueId": "play.all", "AXLabel": "Play all", "AXValue": null,
"role": "AXButton", "type": "Button", "enabled": true,
"frame": {"x":16,"y":420,"width":120,"height":44}, "children": []
}
]
}
]`
func TestParseDescribeUI(t *testing.T) {
roots, err := parseDescribeUI([]byte(sampleTree))
if err != nil {
t.Fatalf("parse error: %v", err)
}
if len(roots) != 1 || len(roots[0].Children) != 2 {
t.Fatalf("got %d roots / %d children, want 1 / 2", len(roots), len(roots[0].Children))
}
}
func TestFindByID(t *testing.T) {
roots, _ := parseDescribeUI([]byte(sampleTree))
matches := findByID(roots, "artist.hero")
if len(matches) != 1 {
t.Fatalf("got %d matches, want 1", len(matches))
}
if got := deref(matches[0].AXLabel); got != "Artwork for The Chemical Brothers" {
t.Errorf("label = %q", got)
}
}
func TestFindByIDAbsent(t *testing.T) {
roots, _ := parseDescribeUI([]byte(sampleTree))
if matches := findByID(roots, "nope"); len(matches) != 0 {
t.Errorf("got %d matches, want 0", len(matches))
}
}
func FuzzParseDescribeUI(f *testing.F) {
f.Add([]byte(sampleTree))
f.Add([]byte(`[]`))
f.Add([]byte(`[{"AXUniqueId":null,"children":[]}]`))
f.Fuzz(func(t *testing.T, data []byte) {
_, _ = parseDescribeUI(data) // must not panic
})
}