mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 12:07:09 +00:00
Merge branch 'master' into fix-violation-step-attribution
This commit is contained in:
commit
56844df028
19 files changed
+981
-190
No files matched your search
@@ -193,6 +193,23 @@ func (d *Driver) InputText(_ context.Context, text string) error {
|
||||
)
|
||||
}
|
||||
|
||||
// EraseText clears the focused field. InputText above already replaces via
|
||||
// select-all, so the character count is not needed to bound the deletion.
|
||||
func (d *Driver) EraseText(_ context.Context, _ int) error {
|
||||
return chromedp.Run(d.tabCtx,
|
||||
chromedp.ActionFunc(func(ctx context.Context) error {
|
||||
if err := chromedp.Evaluate(`
|
||||
(function() {
|
||||
const el = document.activeElement;
|
||||
if (el && typeof el.select === 'function') el.select();
|
||||
})()`, nil).Do(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return input.InsertText("").Do(ctx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
||||
millis := max(duration.Milliseconds(), 50)
|
||||
script := fmt.Sprintf(`
|
||||
|
||||
@@ -20,6 +20,10 @@ type DeviceDriver interface {
|
||||
DoubleTap(ctx context.Context, x, y int) error
|
||||
DoubleTapSelector(ctx context.Context, selector string) error
|
||||
InputText(ctx context.Context, text string) error
|
||||
// EraseText deletes characterCount characters from the focused field.
|
||||
// The runner calls it before InputText so the verb replaces existing
|
||||
// content instead of appending to it.
|
||||
EraseText(ctx context.Context, characterCount int) error
|
||||
Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error
|
||||
PressKey(ctx context.Context, key string) error
|
||||
LongPress(ctx context.Context, x, y int) error
|
||||
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
ActionDoubleTap ActionKind = "double_tap"
|
||||
ActionDoubleTapSelector ActionKind = "double_tap_selector"
|
||||
ActionInputText ActionKind = "input_text"
|
||||
ActionEraseText ActionKind = "erase_text"
|
||||
ActionSwipe ActionKind = "swipe"
|
||||
ActionPressKey ActionKind = "press_key"
|
||||
ActionLongPress ActionKind = "long_press"
|
||||
@@ -32,19 +33,20 @@ const (
|
||||
)
|
||||
|
||||
type Action struct {
|
||||
Kind ActionKind
|
||||
BundleID string
|
||||
ClearState bool
|
||||
X, Y int
|
||||
FromX, FromY int
|
||||
ToX, ToY int
|
||||
Duration time.Duration
|
||||
Selector string
|
||||
Text string
|
||||
Key string
|
||||
LogLevel string
|
||||
LogSince time.Time
|
||||
Idle time.Duration
|
||||
Kind ActionKind
|
||||
BundleID string
|
||||
ClearState bool
|
||||
X, Y int
|
||||
FromX, FromY int
|
||||
ToX, ToY int
|
||||
Duration time.Duration
|
||||
Selector string
|
||||
Text string
|
||||
CharacterCount int
|
||||
Key string
|
||||
LogLevel string
|
||||
LogSince time.Time
|
||||
Idle time.Duration
|
||||
}
|
||||
|
||||
// Driver is an in-memory Driver implementation for unit tests.
|
||||
@@ -210,6 +212,14 @@ func (d *Driver) InputText(ctx context.Context, text string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) EraseText(ctx context.Context, characterCount int) error {
|
||||
if err := d.failure(ActionEraseText); err != nil {
|
||||
return err
|
||||
}
|
||||
d.record(Action{Kind: ActionEraseText, CharacterCount: characterCount})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
||||
if err := d.failure(ActionSwipe); err != nil {
|
||||
return err
|
||||
|
||||
@@ -104,14 +104,18 @@ func (c *Client) TapSelector(ctx context.Context, selector string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both
|
||||
// events inside a sub-100 ms race window, long enough for the sidecar to
|
||||
// serialize two MotionEvent streams. The sidecar exposes no native double-tap
|
||||
// RPC, so the gesture is two Taps with this gap.
|
||||
// doubleTapGap is the inter-tap delay for the selector fallback: short enough
|
||||
// to land both events inside a sub-100 ms race window, long enough for the
|
||||
// sidecar to serialize two MotionEvent streams.
|
||||
const doubleTapGap = 50 * time.Millisecond
|
||||
|
||||
// DoubleTap dispatches the native RPC so the backend can land both taps as
|
||||
// close together as the platform allows. Composing two Tap round trips from
|
||||
// here spreads them by hundreds of milliseconds on iOS, wide enough for
|
||||
// navigation to interleave between the taps.
|
||||
func (c *Client) DoubleTap(ctx context.Context, x, y int) error {
|
||||
return doubleTap(ctx, func() error { return c.Tap(ctx, x, y) })
|
||||
_, err := c.stub.DoubleTap(ctx, &driverpb.Point{X: int32(x), Y: int32(y)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) DoubleTapSelector(ctx context.Context, selector string) error {
|
||||
@@ -137,6 +141,11 @@ func (c *Client) InputText(ctx context.Context, text string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) EraseText(ctx context.Context, characterCount int) error {
|
||||
_, err := c.stub.EraseText(ctx, &driverpb.EraseTextRequest{CharacterCount: int32(characterCount)})
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
|
||||
_, err := c.stub.Swipe(ctx, &driverpb.SwipeRequest{
|
||||
From: &driverpb.Point{X: int32(fromX), Y: int32(fromY)},
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -730,3 +730,145 @@ func TestExplicitPackageAttributeWins(t *testing.T) {
|
||||
t.Errorf("package = %q, want app.folio (explicit attr should win)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// iosFlatDump mirrors the Compose-on-iOS accessibility shape: the testTag node
|
||||
// surfaces as an empty leaf SIBLING of the container holding the content it
|
||||
// labels, with equal bounds, instead of as an ancestor.
|
||||
const iosFlatDump = `{
|
||||
"attributes": {"bounds": "[0,0][402,874]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"accessibilityText": "Folio", "bounds": "[0,0][402,874]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"resource-id": "LoginScreen", "bounds": "[0,62][402,840]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"bounds": "[0,62][402,840]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"accessibilityText": "EMAIL", "bounds": "[20,106][60,120]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "LoginEmail", "accessibilityText": "Email", "bounds": "[34,125][368,173]"},
|
||||
"children": [
|
||||
{"attributes": {"bounds": "[34,125][368,173]"}, "children": []}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "LoginPassword", "accessibilityText": "Password", "bounds": "[34,205][368,253]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"text": "Sign in", "bounds": "[20,297][382,345]"},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"attributes": {"resource-id": "LoginSubmit", "accessibilityText": "Sign in", "bounds": "[20,297][382,345]"},
|
||||
"children": [
|
||||
{"attributes": {"text": "Sign in", "resource-id": "SubmitLabel", "bounds": "[168,312][233,330]"}, "children": []}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attributes": {"bounds": "[0,0][402,54]"},
|
||||
"children": [
|
||||
{
|
||||
"attributes": {"resource-id": "StatusClock", "accessibilityText": "3:24 PM", "bounds": "[55,22][92,42]"},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestIOSFlatSelectorPathFallsBackToBounds(t *testing.T) {
|
||||
tree, err := Parse(iosFlatDump)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
path := []Selector{
|
||||
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
|
||||
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginEmail"}}},
|
||||
}
|
||||
node := tree.FindBySelectorPath(path)
|
||||
if node == nil {
|
||||
t.Fatal("expected LoginEmail via bounds containment under leaf LoginScreen")
|
||||
}
|
||||
if node.ResourceID != "LoginEmail" {
|
||||
t.Fatalf("got %q, want LoginEmail", node.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatStringPathFallsBackToBounds(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
element := tree.Find("id:LoginScreen > id:LoginSubmit")
|
||||
if element == nil {
|
||||
t.Fatal("expected LoginSubmit via bounds containment under leaf LoginScreen")
|
||||
}
|
||||
if element.ResourceID != "LoginSubmit" {
|
||||
t.Fatalf("got %q, want LoginSubmit", element.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatScopedNodeFindFallsBackToBounds(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
screen := tree.FindNode("id:LoginScreen")
|
||||
if screen == nil {
|
||||
t.Fatal("expected LoginScreen node")
|
||||
}
|
||||
node := screen.Find("id:LoginPassword")
|
||||
if node == nil {
|
||||
t.Fatal("expected LoginPassword via bounds containment")
|
||||
}
|
||||
if node.ResourceID != "LoginPassword" {
|
||||
t.Fatalf("got %q, want LoginPassword", node.ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatFindAllBySelectorPathReturnsEachField(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
path := []Selector{
|
||||
{Filters: []AttrFilter{{Attr: "testTag", Value: "LoginScreen"}}},
|
||||
{Filters: []AttrFilter{{Attr: "label", Value: "word"}}},
|
||||
}
|
||||
// label "word" substring-matches "Password" only.
|
||||
nodes := tree.FindAllBySelectorPath(path)
|
||||
if len(nodes) != 1 {
|
||||
t.Fatalf("got %d nodes, want 1", len(nodes))
|
||||
}
|
||||
if nodes[0].ResourceID != "LoginPassword" {
|
||||
t.Fatalf("got %q, want LoginPassword", nodes[0].ResourceID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatSpatialScopeExcludesOutsideBounds(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
// StatusClock sits in the status bar above LoginScreen's bounds; the
|
||||
// spatial fallback must not leak it into the screen's scope.
|
||||
if tree.Find("id:LoginScreen > id:StatusClock") != nil {
|
||||
t.Fatal("StatusClock is outside LoginScreen bounds, expected nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIOSFlatStructuralChildStillPreferred(t *testing.T) {
|
||||
tree, _ := Parse(iosFlatDump)
|
||||
submit := tree.FindNode("id:LoginSubmit")
|
||||
if submit == nil {
|
||||
t.Fatal("expected LoginSubmit node")
|
||||
}
|
||||
// "Sign in" exists as the structural child of LoginSubmit and as an
|
||||
// equal-bounds sibling decoration; the structural child must win.
|
||||
node := submit.Find("text:Sign in")
|
||||
if node == nil {
|
||||
t.Fatal("expected structural child match")
|
||||
}
|
||||
if node.ResourceID != "SubmitLabel" {
|
||||
t.Fatalf("expected the structural child SubmitLabel, got id=%q", node.ResourceID)
|
||||
}
|
||||
}
|
||||
@@ -497,6 +497,15 @@ func applyAction(ctx context.Context, drv driver.DeviceDriver, action verifier.A
|
||||
return err
|
||||
}
|
||||
}
|
||||
// InputText replaces the field's content: erase what the target
|
||||
// holds before typing. Appending instead lets repeated draws grow
|
||||
// the field without bound (e.g. into a max-length validation error
|
||||
// the fuzzer can never escape) and makes retried typing land twice.
|
||||
if count := existingTextLength(action, tree); count > 0 {
|
||||
if err := drv.EraseText(ctx, count); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return drv.InputText(ctx, action.Text)
|
||||
case verifier.ActionKindSwipe:
|
||||
duration := time.Duration(action.DurationMillis) * time.Millisecond
|
||||
@@ -547,6 +556,20 @@ func collectLogs(ctx context.Context, drv driver.DeviceDriver, since time.Time)
|
||||
return result
|
||||
}
|
||||
|
||||
// existingTextLength returns the character count of the InputText target's
|
||||
// current text, so the runner can erase it before typing. Zero when the
|
||||
// target cannot be resolved or holds no text.
|
||||
func existingTextLength(action verifier.Action, tree *hierarchy.Tree) int {
|
||||
if action.On == "" || tree == nil {
|
||||
return 0
|
||||
}
|
||||
element := tree.Find(action.On)
|
||||
if element == nil {
|
||||
return 0
|
||||
}
|
||||
return len([]rune(element.Text))
|
||||
}
|
||||
|
||||
func resolveCoordinates(action verifier.Action, tree *hierarchy.Tree) (int, int, bool) {
|
||||
// When On is empty, X/Y are authoritative (web V8 path emits coordinates
|
||||
// directly from getBoundingClientRect; the runtime nullifies unresolved
|
||||
|
||||
@@ -550,6 +550,52 @@ func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAction_InputTextErasesExistingTextBeforeTyping(t *testing.T) {
|
||||
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
|
||||
{"attributes":{"resource-id":"username","text":"stale-value","bounds":"[10,10,500,100]"},"children":[]}
|
||||
]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
driverMock := mockdriver.New()
|
||||
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
|
||||
|
||||
if err := applyAction(context.Background(), driverMock, action, tree); err != nil {
|
||||
t.Fatalf("applyAction: %v", err)
|
||||
}
|
||||
actions := driverMock.Actions()
|
||||
if len(actions) != 3 {
|
||||
t.Fatalf("want tap, erase, input; got %v", actions)
|
||||
}
|
||||
if actions[0].Kind != mockdriver.ActionTap {
|
||||
t.Errorf("first action = %v, want tap", actions[0].Kind)
|
||||
}
|
||||
if actions[1].Kind != mockdriver.ActionEraseText || actions[1].CharacterCount != len("stale-value") {
|
||||
t.Errorf("second action = %+v, want erase_text of %d characters", actions[1], len("stale-value"))
|
||||
}
|
||||
if actions[2].Kind != mockdriver.ActionInputText || actions[2].Text != "alice" {
|
||||
t.Errorf("third action = %+v, want input_text alice", actions[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAction_InputTextSkipsEraseWhenTargetEmpty(t *testing.T) {
|
||||
tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root","bounds":"[0,0,1080,2340]"},"children":[
|
||||
{"attributes":{"resource-id":"username","bounds":"[10,10,500,100]"},"children":[]}
|
||||
]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
driverMock := mockdriver.New()
|
||||
action := verifier.Action{Kind: verifier.ActionKindInputText, On: "id:username", Text: "alice"}
|
||||
|
||||
if err := applyAction(context.Background(), driverMock, action, tree); err != nil {
|
||||
t.Fatalf("applyAction: %v", err)
|
||||
}
|
||||
if containsAction(driverMock.Actions(), mockdriver.ActionEraseText, "") {
|
||||
t.Errorf("empty field must not be erased: %v", driverMock.Actions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAction_InputTextSurfacesFocusTapError(t *testing.T) {
|
||||
t.Run("selector focus tap fails", func(t *testing.T) {
|
||||
driverMock := mockdriver.New()
|
||||
|
||||
Reference in new issue
Block a user