fix(sidecar): map the driver's refusals onto the gesture errors

OUT_OF_RANGE becomes ErrGestureUndelivered on tap, long press, double tap, swipe and the selector fallback; NOT_FOUND on TapSelector becomes ErrSelectorMatchedNothing. without this the runner reads either as a plain apply failure and counts it toward the failure streak.
This commit is contained in:
pj committed 2026-08-16 17:42:00 +05:30
1 parent 433f786080
commit a245194120
2 files changed
+103 -7

No files matched your search

+21 -5
View File
@@ -8,7 +8,9 @@ import (
"time" "time"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"github.com/priyanshujain/sanderling/internal/android" "github.com/priyanshujain/sanderling/internal/android"
"github.com/priyanshujain/sanderling/internal/driver" "github.com/priyanshujain/sanderling/internal/driver"
@@ -129,19 +131,33 @@ func (c *Client) Terminate(ctx context.Context) error {
return err return err
} }
// asGestureError translates the sidecar's OUT_OF_RANGE refusal of a point the
// device has no surface under into driver.ErrGestureUndelivered. Only the
// sidecar knows the screen extent, and the platform silently drops such a
// gesture, so without this the step reads as an action that landed.
func asGestureError(err error) error {
if status.Code(err) != codes.OutOfRange {
return err
}
return fmt.Errorf("%w: %s", driver.ErrGestureUndelivered, status.Convert(err).Message())
}
func (c *Client) Tap(ctx context.Context, x, y int) error { func (c *Client) Tap(ctx context.Context, x, y int) error {
_, err := c.stub.Tap(ctx, &driverpb.Point{X: int32(x), Y: int32(y)}) _, err := c.stub.Tap(ctx, &driverpb.Point{X: int32(x), Y: int32(y)})
return err return asGestureError(err)
} }
func (c *Client) LongPress(ctx context.Context, x, y int) error { func (c *Client) LongPress(ctx context.Context, x, y int) error {
_, err := c.stub.LongPress(ctx, &driverpb.Point{X: int32(x), Y: int32(y)}) _, err := c.stub.LongPress(ctx, &driverpb.Point{X: int32(x), Y: int32(y)})
return err return asGestureError(err)
} }
func (c *Client) TapSelector(ctx context.Context, selector string) error { func (c *Client) TapSelector(ctx context.Context, selector string) error {
_, err := c.stub.TapSelector(ctx, &driverpb.Selector{Value: selector}) _, err := c.stub.TapSelector(ctx, &driverpb.Selector{Value: selector})
return err if status.Code(err) == codes.NotFound {
return fmt.Errorf("%w: %s", driver.ErrSelectorMatchedNothing, status.Convert(err).Message())
}
return asGestureError(err)
} }
// doubleTapGap is the inter-tap delay for the selector fallback: short enough // doubleTapGap is the inter-tap delay for the selector fallback: short enough
@@ -155,7 +171,7 @@ const doubleTapGap = 50 * time.Millisecond
// navigation to interleave between the taps. // navigation to interleave between the taps.
func (c *Client) DoubleTap(ctx context.Context, x, y int) error { func (c *Client) DoubleTap(ctx context.Context, x, y int) error {
_, err := c.stub.DoubleTap(ctx, &driverpb.Point{X: int32(x), Y: int32(y)}) _, err := c.stub.DoubleTap(ctx, &driverpb.Point{X: int32(x), Y: int32(y)})
return err return asGestureError(err)
} }
func (c *Client) DoubleTapSelector(ctx context.Context, selector string) error { func (c *Client) DoubleTapSelector(ctx context.Context, selector string) error {
@@ -192,7 +208,7 @@ func (c *Client) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration
To: &driverpb.Point{X: int32(toX), Y: int32(toY)}, To: &driverpb.Point{X: int32(toX), Y: int32(toY)},
DurationMillis: duration.Milliseconds(), DurationMillis: duration.Milliseconds(),
}) })
return err return asGestureError(err)
} }
func (c *Client) PressKey(ctx context.Context, key string) error { func (c *Client) PressKey(ctx context.Context, key string) error {
+82 -2
View File
@@ -2,6 +2,7 @@ package sidecar
import ( import (
"context" "context"
"errors"
"io" "io"
"net" "net"
"strings" "strings"
@@ -13,6 +14,7 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"github.com/priyanshujain/sanderling/internal/driver"
driverpb "github.com/priyanshujain/sanderling/proto/driverpb" driverpb "github.com/priyanshujain/sanderling/proto/driverpb"
) )
@@ -46,8 +48,10 @@ type fakeServer struct {
logEntries []*driverpb.LogEntry logEntries []*driverpb.LogEntry
metrics *driverpb.MetricsResponse metrics *driverpb.MetricsResponse
healthError error healthError error
tapError error tapError error
gestureError error
selectorError error
} }
func (s *fakeServer) Health(_ context.Context, _ *driverpb.Empty) (*driverpb.HealthStatus, error) { func (s *fakeServer) Health(_ context.Context, _ *driverpb.Empty) (*driverpb.HealthStatus, error) {
@@ -85,6 +89,9 @@ func (s *fakeServer) Tap(_ context.Context, point *driverpb.Point) (*driverpb.Em
if s.tapError != nil { if s.tapError != nil {
return nil, s.tapError return nil, s.tapError
} }
if s.gestureError != nil {
return nil, s.gestureError
}
s.taps = append(s.taps, point.GetX(), point.GetY()) s.taps = append(s.taps, point.GetX(), point.GetY())
return &driverpb.Empty{}, nil return &driverpb.Empty{}, nil
} }
@@ -92,6 +99,9 @@ func (s *fakeServer) Tap(_ context.Context, point *driverpb.Point) (*driverpb.Em
func (s *fakeServer) TapSelector(_ context.Context, selector *driverpb.Selector) (*driverpb.Empty, error) { func (s *fakeServer) TapSelector(_ context.Context, selector *driverpb.Selector) (*driverpb.Empty, error) {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
if s.selectorError != nil {
return nil, s.selectorError
}
s.tapSelectors = append(s.tapSelectors, selector.GetValue()) s.tapSelectors = append(s.tapSelectors, selector.GetValue())
return &driverpb.Empty{}, nil return &driverpb.Empty{}, nil
} }
@@ -113,6 +123,9 @@ func (s *fakeServer) WaitForIdle(_ context.Context, duration *driverpb.Duration)
func (s *fakeServer) LongPress(_ context.Context, point *driverpb.Point) (*driverpb.Empty, error) { func (s *fakeServer) LongPress(_ context.Context, point *driverpb.Point) (*driverpb.Empty, error) {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
if s.gestureError != nil {
return nil, s.gestureError
}
s.longPresses = append(s.longPresses, point.GetX(), point.GetY()) s.longPresses = append(s.longPresses, point.GetX(), point.GetY())
return &driverpb.Empty{}, nil return &driverpb.Empty{}, nil
} }
@@ -120,6 +133,9 @@ func (s *fakeServer) LongPress(_ context.Context, point *driverpb.Point) (*drive
func (s *fakeServer) DoubleTap(_ context.Context, point *driverpb.Point) (*driverpb.Empty, error) { func (s *fakeServer) DoubleTap(_ context.Context, point *driverpb.Point) (*driverpb.Empty, error) {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
if s.gestureError != nil {
return nil, s.gestureError
}
s.doubleTaps = append(s.doubleTaps, point.GetX(), point.GetY()) s.doubleTaps = append(s.doubleTaps, point.GetX(), point.GetY())
return &driverpb.Empty{}, nil return &driverpb.Empty{}, nil
} }
@@ -127,6 +143,9 @@ func (s *fakeServer) DoubleTap(_ context.Context, point *driverpb.Point) (*drive
func (s *fakeServer) Swipe(_ context.Context, request *driverpb.SwipeRequest) (*driverpb.Empty, error) { func (s *fakeServer) Swipe(_ context.Context, request *driverpb.SwipeRequest) (*driverpb.Empty, error) {
s.mutex.Lock() s.mutex.Lock()
defer s.mutex.Unlock() defer s.mutex.Unlock()
if s.gestureError != nil {
return nil, s.gestureError
}
s.swipes = append(s.swipes, request) s.swipes = append(s.swipes, request)
return &driverpb.Empty{}, nil return &driverpb.Empty{}, nil
} }
@@ -682,3 +701,64 @@ func TestClient_RecentLogsSinceBranches(t *testing.T) {
}) })
} }
} }
// TestClient_SelectorThatMatchesNothingReportsIt keeps a by-selector tap that
// named no element distinguishable from a gesture that reached no point: the
// sidecar refuses it with NOT_FOUND and the client names the resolution
// failure rather than the delivery one.
func TestClient_SelectorThatMatchesNothingReportsIt(t *testing.T) {
taps := map[string]func(*Client) error{
"TapSelector": func(c *Client) error { return c.TapSelector(context.Background(), "id:absent") },
"DoubleTapSelector": func(c *Client) error { return c.DoubleTapSelector(context.Background(), "id:absent") },
}
for name, tap := range taps {
t.Run(name, func(t *testing.T) {
state := newHarness(t)
state.fake.mutex.Lock()
state.fake.selectorError = status.Error(
codes.NotFound, "selector id:absent matched no element")
state.fake.mutex.Unlock()
client, _ := Dial(state.address)
defer client.Close()
err := tap(client)
if !errors.Is(err, driver.ErrSelectorMatchedNothing) {
t.Fatalf("err = %v, want ErrSelectorMatchedNothing", err)
}
if errors.Is(err, driver.ErrGestureUndelivered) {
t.Fatal("a selector that matched nothing must not read as an undelivered gesture")
}
})
}
}
// TestClient_OffScreenGestureReportsUndelivered holds the Android client to the
// contract Chrome and iOS already meet: a gesture the device had no surface
// under reports driver.ErrGestureUndelivered rather than returning nil and
// letting the step read as an action that landed.
func TestClient_OffScreenGestureReportsUndelivered(t *testing.T) {
gestures := map[string]func(*Client) error{
"Tap": func(c *Client) error { return c.Tap(context.Background(), 160, 900) },
"DoubleTap": func(c *Client) error { return c.DoubleTap(context.Background(), 160, 900) },
"LongPress": func(c *Client) error { return c.LongPress(context.Background(), 160, 900) },
"Swipe": func(c *Client) error {
return c.Swipe(context.Background(), 160, 900, 160, 700, time.Second)
},
}
for name, gesture := range gestures {
t.Run(name, func(t *testing.T) {
state := newHarness(t)
state.fake.mutex.Lock()
state.fake.gestureError = status.Error(
codes.OutOfRange, "gesture point (160,900) is outside the 320x640 screen")
state.fake.mutex.Unlock()
client, _ := Dial(state.address)
defer client.Close()
err := gesture(client)
if !errors.Is(err, driver.ErrGestureUndelivered) {
t.Fatalf("err = %v, want ErrGestureUndelivered", err)
}
})
}
}