docs(hierarchy): the package doc states the match rules the code implements

id: was documented as a substring match and is exact-or-suffix-after-":id/", so
id:Button does not match com.x:id/saveButton; desc: is exact-or-prefix-before
", ". Bounds was declared inclusive while Width returns Right - Left, putting
every width, height and centre off by one against its own stated semantics. All
three producers emit exclusive right and bottom, so the code was right and the
comment was the only wrong copy. A test pins the arithmetic, including the
abutting and one-pixel cases the two conventions disagree on.

Also corrects which backend emits which bounds format: the paired form is what
both device backends send, the flat one is chrome and the stub.
This commit is contained in:
pj committed 2026-08-22 21:20:27 +05:30
1 parent d5f3937338
commit 970dc9a6f5
2 files changed
+63 -5

No files matched your search

+10 -5
View File
@@ -5,10 +5,10 @@
//
// String selectors (global scan or element-scoped):
// attribute:value - substring match; exact for "true"/"false" booleans
// id:<suffix> - substring on resource-id / identifier (backward compat)
// id:<value> - exact on resource-id / identifier, or on the local name after ":id/"
// idPrefix:<prefix> - starts-with on resource-id / identifier, package prefix skipped
// text:<value> - substring on text attribute, innermost match only
// desc:<value> - substring on content-desc / accessibilityText
// desc:<value> - exact on content-desc / accessibilityText, or on the part before ", " in an iOS merged label
// descPrefix:<prefix> - starts-with on content-desc / accessibilityText
// tag:<value> - exact match on the element's tag name (web)
//
@@ -40,7 +40,9 @@ import (
"strings"
)
// Bounds is an inclusive rectangle in device pixels.
// Bounds is a rectangle in device pixels. Right and Bottom are exclusive, which
// is how uiautomator, getBoundingClientRect and an XCUIElement frame all report
// them, so Width is Right - Left and a tap at Right lands on the next element.
type Bounds struct {
Left int `json:"left"`
Top int `json:"top"`
@@ -1020,10 +1022,13 @@ func parseSelector(selector string) (string, string, bool) {
return selector[:index], selector[index+1:], true
}
// boundsPattern matches "[l,t,r,b]" (4-value Android/sidecar format).
// boundsPattern matches "[l,t,r,b]", which the chrome driver and the sidecar's
// stub backend emit.
var boundsPattern = regexp.MustCompile(`^\[(-?\d+),(-?\d+),(-?\d+),(-?\d+)\]$`)
// boundsPatternTwo matches "[x1,y1][x2,y2]" (iOS XCUITest format).
// boundsPatternTwo matches "[x1,y1][x2,y2]", which both device backends emit:
// it is uiautomator's own form on Android and what hierarchymap builds from an
// XCUIElement frame on iOS.
var boundsPatternTwo = regexp.MustCompile(`^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$`)
func parseBounds(text string) (Bounds, error) {
+53
View File
@@ -78,6 +78,59 @@ func TestBoundsCenter(t *testing.T) {
}
}
func TestBoundsRightAndBottomAreExclusive(t *testing.T) {
dump := `{
"attributes": {"resource-id": "screen", "bounds": "[0,0,1080,2340]"},
"children": [
{"attributes": {"resource-id": "upper", "bounds": "[0,0,100,50]"}, "children": []},
{"attributes": {"resource-id": "lower", "bounds": "[0,50,100,100]"}, "children": []},
{"attributes": {"resource-id": "hairlineFlat", "bounds": "[7,3,8,4]"}, "children": []},
{"attributes": {"resource-id": "hairlinePairs", "bounds": "[7,3][8,4]"}, "children": []}
]
}`
tree, err := Parse(dump)
if err != nil {
t.Fatalf("Parse: %v", err)
}
for _, want := range []struct {
selector string
width, height, centerX, centerY int
}{
{"id:screen", 1080, 2340, 540, 1170},
{"id:upper", 100, 50, 50, 25},
{"id:lower", 100, 50, 50, 75},
{"id:hairlineFlat", 1, 1, 7, 3},
{"id:hairlinePairs", 1, 1, 7, 3},
} {
t.Run(want.selector, func(t *testing.T) {
element := tree.Find(want.selector)
if element == nil {
t.Fatalf("no match for %s", want.selector)
}
if got := element.Bounds.Width(); got != want.width {
t.Errorf("width %d, want %d", got, want.width)
}
if got := element.Bounds.Height(); got != want.height {
t.Errorf("height %d, want %d", got, want.height)
}
x, y := element.Bounds.Center()
if x != want.centerX || y != want.centerY {
t.Errorf("center %d,%d, want %d,%d", x, y, want.centerX, want.centerY)
}
})
}
upper, lower := tree.Find("id:upper"), tree.Find("id:lower")
if upper.Bounds.Bottom != lower.Bounds.Top {
t.Fatalf("fixture stopped abutting: %d, %d", upper.Bounds.Bottom, lower.Bounds.Top)
}
if upper.Bounds.Height()+lower.Bounds.Height() != lower.Bounds.Bottom-upper.Bounds.Top {
t.Errorf("abutting rows cover %d+%d of %d",
upper.Bounds.Height(), lower.Bounds.Height(), lower.Bounds.Bottom-upper.Bounds.Top)
}
}
func TestParseEmpty(t *testing.T) {
tree, err := Parse("")
if err != nil {