mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
fix: iOS spec driving, InputText replace semantics, native DoubleTap (#58)
* fix(hierarchy): bounds-containment fallback for scoped and path queries Compose on iOS surfaces a testTag node as an empty leaf sibling of the content it labels instead of as an ancestor, so descendant search under the tagged node finds nothing and every path or scoped query returns null. When structural search yields no match, fall back to nodes whose bounds lie inside the scope node's bounds. * feat(sidecar): derive iOS clickable and editable from element type The XCTest hierarchy mapping dropped the element type, leaving no clickable or editable flags on iOS, so the fuzzer's tap and typing verbs never found a candidate inside the app. Map the raw accessibility tree directly and derive clickable, editable, scrollable, and class from the XCUIElementType raw value. * feat(proto): add EraseText RPC for InputText replace semantics * feat(driver): add EraseText to the device driver surface * fix(runner): erase existing field text before InputText InputText appended on native platforms, so repeated draws grew fields without bound. The folio fuzz run wedged on the add-account screen: each draw concatenated another name until the 40-character validation error became permanent. Replace semantics also makes retried typing idempotent. The web driver already replaced via select-all; native now matches. * feat(sidecar): EraseText backend support on android and ios * fix(folio): saturation-gate account creation in the spec The 2-3 step add-account loop outcompeted the 5-step transaction chain at every weighted re-draw, so runs filled with account creation and rarely exercised the balance properties. Stop offering add-account once three accounts exist; the renormalized weights then favor the transaction flow at every step of its chain. * fix(folio): author spec weights to match testing intent Revert the account saturation gate: it starved newAccountBalanceIsZero once it tripped, and a magic account count is app-state tuning, not intent. Instead weight the generators by what the properties need: the transaction chain leads, account creation stays exercised, and doubleTaps gets explicit weight everywhere because double-submission idempotency is what the spec is testing for. * fix(folio): lower doubleTaps weight to 5 * fix(sidecar): surface visible text on iOS static elements Static text and button strings live in the accessibility label on iOS, so the text attribute came through empty and every balance extractor parsed to zero, silently disarming both folio properties. Non-editable elements now fall back title, value, then label; editable fields keep value-only so an empty field's caption does not read as content. * feat(driver): native DoubleTap RPC for a tight inter-tap gap Composing two Tap round trips from the Go client spread the taps by hundreds of milliseconds on iOS, wide enough for the app to navigate between them, so double-submission races could never reproduce. The sidecar now lands both taps back-to-back next to the device transport. * feat(sidecar): pipeline iOS double-tap requests Queue the second tap at the XCTest runner while the first executes. The runner serializes handlers, so this is the tightest gap the transport allows (~350ms per tap round trip); recorded here with measurements for the iOS double-tap limitation.
This commit is contained in:
19 files changed
+981
-190
No files matched your search
+109
-72
@@ -75,6 +75,7 @@ type Element struct {
|
||||
type Node struct {
|
||||
Element
|
||||
Children []*Node `json:"-"`
|
||||
tree *Tree
|
||||
}
|
||||
|
||||
// Tree is a flat collection of every node in a hierarchy dump, in pre-order.
|
||||
@@ -180,7 +181,7 @@ func Parse(text string) (*Tree, error) {
|
||||
}
|
||||
|
||||
func walkNode(node *treeNodeJSON, tree *Tree) *Node {
|
||||
n := &Node{Element: *elementFromNode(node)}
|
||||
n := &Node{Element: *elementFromNode(node), tree: tree}
|
||||
tree.Elements = append(tree.Elements, &n.Element)
|
||||
for i := range node.Children {
|
||||
n.Children = append(n.Children, walkNode(&node.Children[i], tree))
|
||||
@@ -333,69 +334,50 @@ func (t *Tree) FindAllBySelectorPath(path []Selector) []*Node {
|
||||
return t.Root.FindAllBySelectorPath(path)
|
||||
}
|
||||
|
||||
// Find returns the first Node in this node's subtree (descendants only) matching
|
||||
// the string selector. Path queries within the selector are not supported here.
|
||||
// Find returns the first Node scoped to this node (descendants, with spatial
|
||||
// fallback) matching the string selector. Path queries within the selector are
|
||||
// not supported here.
|
||||
func (n *Node) Find(selector string) *Node {
|
||||
kind, value, ok := parseSelector(selector)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, child := range n.Children {
|
||||
if nodes := searchSubtree(child, kind, value); len(nodes) > 0 {
|
||||
return nodes[0]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return firstNode(n.FindAll(selector))
|
||||
}
|
||||
|
||||
// FindAll returns all Nodes in this node's subtree (descendants only) matching
|
||||
// the string selector.
|
||||
// FindAll returns all Nodes scoped to this node (descendants, with spatial
|
||||
// fallback) matching the string selector.
|
||||
func (n *Node) FindAll(selector string) []*Node {
|
||||
kind, value, ok := parseSelector(selector)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
for _, child := range n.Children {
|
||||
result = append(result, searchSubtree(child, kind, value)...)
|
||||
}
|
||||
return result
|
||||
return n.scopedNodes(func(element *Element) bool {
|
||||
return match(element, kind, value)
|
||||
})
|
||||
}
|
||||
|
||||
// FindBySelector returns the first Node in this node's subtree matching sel (AND semantics).
|
||||
// FindBySelector returns the first Node scoped to this node matching sel (AND semantics).
|
||||
func (n *Node) FindBySelector(sel Selector) *Node {
|
||||
for _, child := range n.Children {
|
||||
if nodes := searchSubtreeBySelector(child, sel); len(nodes) > 0 {
|
||||
return nodes[0]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return firstNode(n.FindAllBySelector(sel))
|
||||
}
|
||||
|
||||
// FindAllBySelector returns all Nodes in this node's subtree matching sel (AND semantics).
|
||||
// FindAllBySelector returns all Nodes scoped to this node matching sel (AND semantics).
|
||||
func (n *Node) FindAllBySelector(sel Selector) []*Node {
|
||||
var result []*Node
|
||||
for _, child := range n.Children {
|
||||
result = append(result, searchSubtreeBySelector(child, sel)...)
|
||||
}
|
||||
return result
|
||||
return n.scopedNodes(func(element *Element) bool {
|
||||
return matchSelector(element, sel)
|
||||
})
|
||||
}
|
||||
|
||||
// FindBySelectorPath walks a chain of selectors. The first selector is matched
|
||||
// against descendants of the receiver; each subsequent selector is matched
|
||||
// against descendants of the previous match. Returns the deepest match or nil.
|
||||
// in the receiver's scope; each subsequent selector is matched in the scope of
|
||||
// the previous match. Returns the deepest match or nil.
|
||||
func (n *Node) FindBySelectorPath(path []Selector) *Node {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, child := range n.Children {
|
||||
for _, candidate := range searchSubtreeBySelector(child, path[0]) {
|
||||
if len(path) == 1 {
|
||||
return candidate
|
||||
}
|
||||
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
|
||||
return deeper
|
||||
}
|
||||
for _, candidate := range n.FindAllBySelector(path[0]) {
|
||||
if len(path) == 1 {
|
||||
return candidate
|
||||
}
|
||||
if deeper := candidate.FindBySelectorPath(path[1:]); deeper != nil {
|
||||
return deeper
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -407,19 +389,86 @@ func (n *Node) FindAllBySelectorPath(path []Selector) []*Node {
|
||||
if len(path) == 0 {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
for _, candidate := range n.FindAllBySelector(path[0]) {
|
||||
if len(path) == 1 {
|
||||
result = append(result, candidate)
|
||||
continue
|
||||
}
|
||||
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func firstNode(nodes []*Node) *Node {
|
||||
if len(nodes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
|
||||
// scopedNodes returns this node's descendants matching accept, in pre-order.
|
||||
// When no descendant matches, nodes spatially contained in this node's bounds
|
||||
// are matched instead. Compose on iOS emits a testTag node as an empty leaf
|
||||
// sibling of the content it labels rather than as an ancestor, so descendant
|
||||
// search under the tagged node finds nothing; bounds containment recovers the
|
||||
// intended scope.
|
||||
func (n *Node) scopedNodes(accept func(*Element) bool) []*Node {
|
||||
var result []*Node
|
||||
for _, child := range n.Children {
|
||||
for _, candidate := range searchSubtreeBySelector(child, path[0]) {
|
||||
if len(path) == 1 {
|
||||
result = append(result, candidate)
|
||||
continue
|
||||
}
|
||||
result = append(result, candidate.FindAllBySelectorPath(path[1:])...)
|
||||
collectMatches(child, accept, &result)
|
||||
}
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
for _, candidate := range n.spatialScope() {
|
||||
if accept(&candidate.Element) {
|
||||
result = append(result, candidate)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectMatches(node *Node, accept func(*Element) bool, result *[]*Node) {
|
||||
if accept(&node.Element) {
|
||||
*result = append(*result, node)
|
||||
}
|
||||
for _, child := range node.Children {
|
||||
collectMatches(child, accept, result)
|
||||
}
|
||||
}
|
||||
|
||||
// spatialScope returns every node in the tree, in pre-order, whose positive
|
||||
// bounds lie fully inside this node's bounds, excluding the node itself.
|
||||
func (n *Node) spatialScope() []*Node {
|
||||
if n.tree == nil || n.tree.Root == nil {
|
||||
return nil
|
||||
}
|
||||
if n.Bounds.Width() <= 0 || n.Bounds.Height() <= 0 {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
var walk func(*Node)
|
||||
walk = func(candidate *Node) {
|
||||
if candidate != n &&
|
||||
candidate.Bounds.Width() > 0 && candidate.Bounds.Height() > 0 &&
|
||||
containsBounds(n.Bounds, candidate.Bounds) {
|
||||
result = append(result, candidate)
|
||||
}
|
||||
for _, child := range candidate.Children {
|
||||
walk(child)
|
||||
}
|
||||
}
|
||||
walk(n.tree.Root)
|
||||
return result
|
||||
}
|
||||
|
||||
// containsBounds reports whether outer fully contains inner (inclusive).
|
||||
func containsBounds(outer, inner Bounds) bool {
|
||||
return inner.Left >= outer.Left && inner.Top >= outer.Top &&
|
||||
inner.Right <= outer.Right && inner.Bottom <= outer.Bottom
|
||||
}
|
||||
|
||||
func findPathNode(root *Node, segments []string) *Node {
|
||||
if root == nil || len(segments) == 0 {
|
||||
return nil
|
||||
@@ -440,18 +489,12 @@ func findPathNode(root *Node, segments []string) *Node {
|
||||
}
|
||||
|
||||
func findPathDescendantsNode(root *Node, segments []string) *Node {
|
||||
kind, value, ok := parseSelector(segments[0])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, child := range root.Children {
|
||||
for _, node := range searchSubtree(child, kind, value) {
|
||||
if len(segments) == 1 {
|
||||
return node
|
||||
}
|
||||
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
|
||||
return result
|
||||
}
|
||||
for _, node := range root.FindAll(segments[0]) {
|
||||
if len(segments) == 1 {
|
||||
return node
|
||||
}
|
||||
if result := findPathDescendantsNode(node, segments[1:]); result != nil {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -477,19 +520,13 @@ func findPathAllNodes(root *Node, segments []string) []*Node {
|
||||
}
|
||||
|
||||
func findPathAllDescendantsNodes(root *Node, segments []string) []*Node {
|
||||
kind, value, ok := parseSelector(segments[0])
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []*Node
|
||||
for _, child := range root.Children {
|
||||
for _, node := range searchSubtree(child, kind, value) {
|
||||
if len(segments) == 1 {
|
||||
result = append(result, node)
|
||||
continue
|
||||
}
|
||||
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
|
||||
for _, node := range root.FindAll(segments[0]) {
|
||||
if len(segments) == 1 {
|
||||
result = append(result, node)
|
||||
continue
|
||||
}
|
||||
result = append(result, findPathAllDescendantsNodes(node, segments[1:])...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user