fix(ios): refuse a gesture the screen has no surface under

the hierarchy reaches past the screen wherever a scroll container holds content below the fold, so an action derived from it can name a point no touch lands on. tap, double tap, long press and swipe now report ErrGestureUndelivered for such a point, the far edge exclusive because a touch at x == screenWidth arrives at screenWidth-1. resolveSelectorCenter reports ErrSelectorMatchedNothing rather than a bare error.
This commit is contained in:
pj committed 2026-08-16 17:41:38 +05:30
1 parent da3cc5f68c
commit 0ab5c305e3
2 files changed
+141 -1

No files matched your search

+22 -1
View File
@@ -594,7 +594,19 @@ func (d *Driver) Terminate(ctx context.Context) error {
}) })
} }
// offScreen reports a point the device has no surface under. The hierarchy
// reaches past the screen wherever a scroll container holds content beyond the
// fold, so an action derived from it can name a point no touch can land on.
// The far edge is exclusive: a touch at x == screenWidth arrives at
// screenWidth-1, which is a point the action never named.
func (d *Driver) offScreen(x, y int) bool {
return x < 0 || y < 0 || x >= d.screenWidth || y >= d.screenHeight
}
func (d *Driver) Tap(ctx context.Context, x, y int) error { func (d *Driver) Tap(ctx context.Context, x, y int) error {
if d.offScreen(x, y) {
return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, x, y)
}
d.mu.Lock() d.mu.Lock()
d.lastTap.x = float64(x) d.lastTap.x = float64(x)
d.lastTap.y = float64(y) d.lastTap.y = float64(y)
@@ -606,18 +618,27 @@ func (d *Driver) Tap(ctx context.Context, x, y int) error {
} }
func (d *Driver) DoubleTap(ctx context.Context, x, y int) error { func (d *Driver) DoubleTap(ctx context.Context, x, y int) error {
if d.offScreen(x, y) {
return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, x, y)
}
return d.withRecovery(ctx, func() error { return d.withRecovery(ctx, func() error {
return d.companion.SendHID(ctx, doubleTapEvents(float64(x), float64(y), d.doubleTapGapMilliseconds)...) return d.companion.SendHID(ctx, doubleTapEvents(float64(x), float64(y), d.doubleTapGapMilliseconds)...)
}) })
} }
func (d *Driver) LongPress(ctx context.Context, x, y int) error { func (d *Driver) LongPress(ctx context.Context, x, y int) error {
if d.offScreen(x, y) {
return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, x, y)
}
return d.withRecovery(ctx, func() error { return d.withRecovery(ctx, func() error {
return d.companion.SendHID(ctx, longPressEvents(float64(x), float64(y), longPressHoldMilliseconds)...) return d.companion.SendHID(ctx, longPressEvents(float64(x), float64(y), longPressHoldMilliseconds)...)
}) })
} }
func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error { func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error {
if d.offScreen(fromX, fromY) {
return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, fromX, fromY)
}
seconds := duration.Seconds() seconds := duration.Seconds()
if seconds <= 0 { if seconds <= 0 {
seconds = 0.25 seconds = 0.25
@@ -707,7 +728,7 @@ func (d *Driver) resolveSelectorCenter(ctx context.Context, selector string) (in
} }
element := tree.Find(selector) element := tree.Find(selector)
if element == nil { if element == nil {
return 0, 0, fmt.Errorf("selector %q matched no element", selector) return 0, 0, fmt.Errorf("%w: %q", driver.ErrSelectorMatchedNothing, selector)
} }
x, y := element.Bounds.Center() x, y := element.Bounds.Center()
return x, y, nil return x, y, nil
+119
View File
@@ -12,6 +12,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -1023,3 +1024,121 @@ func TestAcquireDeviceLockKeepsDistinctDevicesIndependent(t *testing.T) {
} }
defer second.Close() defer second.Close()
} }
func TestSwipeReportsAGestureThatStartsOffScreen(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
// The hierarchy extends past the screen whenever a scroll container holds
// content below the fold, so the runner's clamp to the tree's extent can
// place a gesture where the device has no surface to receive it.
err := d.Swipe(
context.Background(),
200,
1200,
200,
800,
300*time.Millisecond,
)
if !errors.Is(err, driver.ErrGestureUndelivered) {
t.Fatalf(
"Swipe starting below the screen: err = %v, want ErrGestureUndelivered",
err,
)
}
if slices.Contains(companion.recorded(), "hid") {
t.Fatal("an undeliverable swipe must not reach the companion")
}
}
func TestSwipeOnScreenReachesTheCompanion(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
if err := d.Swipe(context.Background(), 200, 600, 200, 200, 300*time.Millisecond); err != nil {
t.Fatalf("Swipe: %v", err)
}
if !slices.Contains(companion.recorded(), "hid") {
t.Fatal("an on-screen swipe should reach the companion")
}
}
// TestTapSelectorReportsASelectorThatMatchesNothing keeps a by-selector tap
// that resolved to no element out of the steps that read as dispatched.
func TestTapSelectorReportsASelectorThatMatchesNothing(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
err := d.TapSelector(context.Background(), "id:absent")
if !errors.Is(err, driver.ErrSelectorMatchedNothing) {
t.Fatalf("TapSelector on an absent element: err = %v, want ErrSelectorMatchedNothing", err)
}
if slices.Contains(companion.recorded(), "hid") {
t.Fatal("a selector that matched nothing must not reach the companion")
}
}
// TestGesturesRefuseTheFarEdgeOfTheScreen holds iOS to the extent the device
// enforces. A tap at x == screenWidth is not delivered at that point on the
// simulator, so admitting it dispatches a gesture the app receives somewhere
// other than where the trace says it landed.
func TestGesturesRefuseTheFarEdgeOfTheScreen(t *testing.T) {
gestures := map[string]func(*Driver, int, int) error{
"Tap": func(d *Driver, x, y int) error { return d.Tap(context.Background(), x, y) },
"DoubleTap": func(d *Driver, x, y int) error { return d.DoubleTap(context.Background(), x, y) },
"LongPress": func(d *Driver, x, y int) error { return d.LongPress(context.Background(), x, y) },
"Swipe": func(d *Driver, x, y int) error {
return d.Swipe(context.Background(), x, y, 200, 400, 300*time.Millisecond)
},
}
for name, gesture := range gestures {
t.Run(name, func(t *testing.T) {
for _, point := range []struct{ x, y int }{{390, 400}, {200, 844}} {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
err := gesture(d, point.x, point.y)
if !errors.Is(err, driver.ErrGestureUndelivered) {
t.Fatalf("%s at (%d,%d): err = %v, want ErrGestureUndelivered",
name, point.x, point.y, err)
}
if slices.Contains(companion.recorded(), "hid") {
t.Fatalf("%s at (%d,%d) reached the companion", name, point.x, point.y)
}
}
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
if err := gesture(d, 389, 843); err != nil {
t.Fatalf("%s at the last on-screen point: %v", name, err)
}
})
}
}
// TestTapReportsAPointOffScreen is the iOS half of letting the runner hand off
// every point it resolved. The runner no longer refuses a point outside the
// screen, because a driver that can scroll reaches it; this one cannot, and a
// point with no surface under it has to be reported rather than synthesised
// into nothing.
func TestTapReportsAPointOffScreen(t *testing.T) {
for _, point := range []struct {
name string
x, y int
}{
{"above the screen", 200, -208},
{"below the screen", 200, 1200},
} {
t.Run(point.name, func(t *testing.T) {
companion := &fakeCompanion{accessibilityJSON: "[]"}
d := newTestDriver(companion)
err := d.Tap(context.Background(), point.x, point.y)
if !errors.Is(err, driver.ErrGestureUndelivered) {
t.Fatalf("Tap at (%d,%d): err = %v, want ErrGestureUndelivered",
point.x, point.y, err)
}
if slices.Contains(companion.recorded(), "hid") {
t.Fatal("an undeliverable tap must not reach the companion")
}
})
}
}