From 970dc9a6f526c8b9aae68809b3d7449d73ace959 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:20:27 +0530 Subject: [PATCH] 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. --- internal/hierarchy/hierarchy.go | 15 +++++--- internal/hierarchy/hierarchy_test.go | 53 ++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/internal/hierarchy/hierarchy.go b/internal/hierarchy/hierarchy.go index 02ba6d8..2592c3c 100644 --- a/internal/hierarchy/hierarchy.go +++ b/internal/hierarchy/hierarchy.go @@ -5,10 +5,10 @@ // // String selectors (global scan or element-scoped): // attribute:value - substring match; exact for "true"/"false" booleans -// id: - substring on resource-id / identifier (backward compat) +// id: - exact on resource-id / identifier, or on the local name after ":id/" // idPrefix: - starts-with on resource-id / identifier, package prefix skipped // text: - substring on text attribute, innermost match only -// desc: - substring on content-desc / accessibilityText +// desc: - exact on content-desc / accessibilityText, or on the part before ", " in an iOS merged label // descPrefix: - starts-with on content-desc / accessibilityText // tag: - 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) { diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 02e6bd5..dcd58a1 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -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 {