mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
fix(xcui): tolerate non-string AXValue so one slider stops breaking the tree
Reported from ExampleApp: `xcui assert` died with "cannot unmarshal number into Go
struct field AXElement.AXValue of type string".
AXValue is not consistently a string — a slider reports 0.5, a page control an
integer, a toggle a bool. Typed as *string, encoding/json failed the WHOLE
document, so a single numeric value anywhere on screen took down every command
that reads the accessibility tree: assert, wait, dialog, and voiceover together.
Measured on a stock Settings screen: 258 null, 10 string, 5 number — not an
exotic control.
Pre-existing, not a regression: axtree.go was last touched in aefd0240 and every
release since has shipped it. What is new is that it was found.
New AXText type accepts string, number, bool, or null and keeps the literal text
(0.5, 3, true) — which is what a user comparing against `--value 0.5` would type.
Applied to every text-ish field, not just the reported one: the cost of guessing
wrong about any of them is total parse failure, and there is no upside to
strictness when xcui compares them as text either way.
Also fixed a nil-ordering bug introduced while converting findByID — an empty
--id would have matched every element lacking an identifier.
9 regression tests: all six JSON shapes for AXValue, every text field carrying a
number, and numeric-identifier lookup.
This commit is contained in:
Binary file not shown.
+41
-9
@@ -10,15 +10,47 @@ type Frame struct {
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
// AXText is a string field that also accepts the numbers and booleans AXe emits.
|
||||
//
|
||||
// AXValue is not consistently a string: a slider reports 0.5, a page control an
|
||||
// integer, a toggle a bool. Typing it as *string made encoding/json fail the
|
||||
// WHOLE document — "cannot unmarshal number into Go struct field
|
||||
// AXElement.AXValue of type string" — so a single numeric value anywhere on
|
||||
// screen broke every command that reads the tree: assert, wait, dialog,
|
||||
// voiceover. Measured on a stock Settings screen: 258 null, 10 string, 5 number.
|
||||
//
|
||||
// Applied to every text-ish field, not just AXValue. The cost of being wrong
|
||||
// about one field's type is total parse failure, and there is no upside to
|
||||
// being strict here — xcui compares these as text either way.
|
||||
type AXText string
|
||||
|
||||
func (a *AXText) UnmarshalJSON(b []byte) error {
|
||||
if len(b) == 0 || string(b) == "null" {
|
||||
return nil
|
||||
}
|
||||
if b[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*a = AXText(s)
|
||||
return nil
|
||||
}
|
||||
// Number, bool, or anything else scalar: keep the literal JSON text, which is
|
||||
// what a user comparing against `--value 0.5` or `--value true` would type.
|
||||
*a = AXText(b)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"`
|
||||
AXUniqueID *AXText `json:"AXUniqueId"`
|
||||
AXLabel *AXText `json:"AXLabel"`
|
||||
AXValue *AXText `json:"AXValue"`
|
||||
Title *AXText `json:"title"`
|
||||
Help *AXText `json:"help"`
|
||||
Subrole *AXText `json:"subrole"`
|
||||
Role string `json:"role"`
|
||||
RoleDescription string `json:"role_description"`
|
||||
Type string `json:"type"`
|
||||
@@ -50,16 +82,16 @@ func walk(roots []AXElement, visit func(AXElement)) {
|
||||
func findByID(roots []AXElement, id string) []AXElement {
|
||||
var out []AXElement
|
||||
walk(roots, func(el AXElement) {
|
||||
if el.AXUniqueID != nil && *el.AXUniqueID == id {
|
||||
if el.AXUniqueID != nil && deref(el.AXUniqueID) == id {
|
||||
out = append(out, el)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
func deref(s *AXText) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
return string(*s)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseDescribeUIToleratesNonStringAXValue(t *testing.T) {
|
||||
// Reported from ExampleApp 2026-08-15: `xcui assert` died with
|
||||
// "cannot unmarshal number into Go struct field AXElement.AXValue of type
|
||||
// string". A single numeric AXValue anywhere on screen failed the WHOLE
|
||||
// document, so assert/wait/dialog/voiceover all broke together. Measured on a
|
||||
// stock Settings screen: 258 null, 10 string, 5 NUMBER — not an exotic control.
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{"number (slider)", `[{"AXValue":0.5,"role":"AXSlider"}]`, "0.5"},
|
||||
{"integer (page control)", `[{"AXValue":3,"role":"AXPageControl"}]`, "3"},
|
||||
{"bool (toggle)", `[{"AXValue":true,"role":"AXSwitch"}]`, "true"},
|
||||
{"string (unchanged)", `[{"AXValue":"On","role":"AXSwitch"}]`, "On"},
|
||||
{"null stays absent", `[{"AXValue":null,"role":"AXGroup"}]`, ""},
|
||||
{"absent stays absent", `[{"role":"AXGroup"}]`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
roots, err := parseDescribeUI([]byte(c.json))
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
if got := deref(roots[0].AXValue); got != c.want {
|
||||
t.Errorf("AXValue = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonStringToleranceCoversEveryTextField(t *testing.T) {
|
||||
// The cost of guessing wrong about ANY of these is total parse failure, so
|
||||
// every text-ish field is tolerant, not just the one that was reported.
|
||||
data := []byte(`[{"AXUniqueId":42,"AXLabel":7,"AXValue":0.5,"title":1,"help":2,"subrole":3,"role":"AXSlider"}]`)
|
||||
roots, err := parseDescribeUI(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parse failed: %v", err)
|
||||
}
|
||||
el := roots[0]
|
||||
for field, got := range map[string]string{
|
||||
"AXUniqueId": deref(el.AXUniqueID), "AXLabel": deref(el.AXLabel),
|
||||
"AXValue": deref(el.AXValue), "title": deref(el.Title),
|
||||
"help": deref(el.Help), "subrole": deref(el.Subrole),
|
||||
} {
|
||||
if got == "" {
|
||||
t.Errorf("%s came back empty; a numeric value must survive as text", field)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByIDMatchesNumericIdentifiers(t *testing.T) {
|
||||
// A numeric AXUniqueId must be findable by its literal text, and an empty
|
||||
// --id must not match elements that simply have no identifier.
|
||||
roots, err := parseDescribeUI([]byte(`[{"AXUniqueId":42,"role":"AXButton"},{"role":"AXGroup"}]`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(findByID(roots, "42")); got != 1 {
|
||||
t.Errorf("findByID(\"42\") matched %d, want 1", got)
|
||||
}
|
||||
if got := len(findByID(roots, "")); got != 0 {
|
||||
t.Errorf("empty id matched %d elements, want 0", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user