diff --git a/internal/driver/ioscompanion/driver.go b/internal/driver/ioscompanion/driver.go index a295192..f0cd8ba 100644 --- a/internal/driver/ioscompanion/driver.go +++ b/internal/driver/ioscompanion/driver.go @@ -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 { + if d.offScreen(x, y) { + return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, x, y) + } d.mu.Lock() d.lastTap.x = float64(x) 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 { + if d.offScreen(x, y) { + return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, x, y) + } return d.withRecovery(ctx, func() error { return d.companion.SendHID(ctx, doubleTapEvents(float64(x), float64(y), d.doubleTapGapMilliseconds)...) }) } 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.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 { + if d.offScreen(fromX, fromY) { + return fmt.Errorf("%w: (%d,%d)", driver.ErrGestureUndelivered, fromX, fromY) + } seconds := duration.Seconds() if seconds <= 0 { seconds = 0.25 @@ -707,7 +728,7 @@ func (d *Driver) resolveSelectorCenter(ctx context.Context, selector string) (in } element := tree.Find(selector) 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() return x, y, nil diff --git a/internal/driver/ioscompanion/driver_test.go b/internal/driver/ioscompanion/driver_test.go index a70ae6a..856af80 100644 --- a/internal/driver/ioscompanion/driver_test.go +++ b/internal/driver/ioscompanion/driver_test.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "sync" "testing" @@ -1023,3 +1024,121 @@ func TestAcquireDeviceLockKeepsDistinctDevicesIndependent(t *testing.T) { } 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") + } + }) + } +}