diff --git a/tools/xcui/axtree.go b/tools/xcui/axtree.go new file mode 100644 index 00000000..3f7e2b08 --- /dev/null +++ b/tools/xcui/axtree.go @@ -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 +} diff --git a/tools/xcui/main_test.go b/tools/xcui/main_test.go index c170fee4..48704b24 100644 --- a/tools/xcui/main_test.go +++ b/tools/xcui/main_test.go @@ -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 + }) +}