Repository: wix/Detox
Pull request #1465 (2019-06-20 07:43:20)

fix: conduct cleanup even if app has been already terminated
- [x] This change has been discussed in issue #1460 and the solution has been agreed upon with maintainers.

rotemmiz: Looks good IMO.
Did the new test fail before this change?
noomorph: Yes, it fails. At least, I've verified on Android.

Patch:
diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index 6d6b86e35..91d3d7024 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -75,6 +75,7 @@ dependencies {
     api 'androidx.test:runner:1.1.1'
     api 'androidx.test:rules:1.1.1'
     api 'androidx.test.ext:junit:1.1.0'
+    api 'androidx.annotation:annotation:1.1.0'
 
     // Version is the latest; Cannot sync with the Github repo (e.g. android/android-test) because the androidx
     // packaging version of associated classes is simply not there...
diff --git a/detox/android/detox/detox-publishing.gradle b/detox/android/detox/detox-publishing.gradle
index 8df91e9d6..812f4f992 100644
--- a/detox/android/detox/detox-publishing.gradle
+++ b/detox/android/detox/detox-publishing.gradle
@@ -12,7 +12,7 @@ dokka {
     sourceDirs = files(android.sourceSets.main.java.srcDirs)
 
     // Nothing to add to what kotlinTasks() resolves on its own
-    classpath = []
+//    classpath = []
 //    classpath += files(android.bootClasspath)
 //    classpath += files(project.configurations.getByName('compile').asList())
 //    classpath += files(android.libraryVariants.collect { variant ->
@@ -25,7 +25,8 @@ dokka {
     outputFormat = 'javadoc' // https://github.com/Kotlin/dokka#output-formats
     outputDirectory = "$buildDir/dokkaDoc" // Temp 'exploded' dir for .jar creation (i.e. by dokkaDocJar task)
 
-    def suppressedPackages = ["androidx", "android_libs"]
+
+    def suppressedPackages = ["android_libs", "com.wix.detox.espresso.common.annot"]
     for (String packagePrefix: suppressedPackages) {
         packageOptions {
             prefix = packagePrefix
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
index e7299900d..91bfa3066 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
@@ -3,9 +3,12 @@ package com.wix.detox.espresso;
 import android.view.InputDevice;
 import android.view.MotionEvent;
 import android.view.View;
-import android.widget.AbsListView;
-import android.widget.HorizontalScrollView;
-import android.widget.ScrollView;
+
+import com.wix.detox.espresso.DetoxErrors.DetoxRuntimeException;
+import com.wix.detox.espresso.DetoxErrors.StaleActionException;
+import com.wix.detox.espresso.common.annot.MotionDir;
+import com.wix.detox.espresso.scroll.ScrollEdgeException;
+import com.wix.detox.espresso.scroll.ScrollHelper;
 
 import org.hamcrest.Matcher;
 
@@ -26,6 +29,10 @@ import static androidx.test.espresso.action.ViewActions.swipeRight;
 import static androidx.test.espresso.action.ViewActions.swipeUp;
 import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_DOWN;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_LEFT;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_RIGHT;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_UP;
 import static org.hamcrest.Matchers.allOf;
 
 
@@ -65,13 +72,7 @@ public class DetoxAction {
     /**
      * Scrolls to the edge of the given scrollable view.
      *
-     * Edge
-     * 1 -> Left
-     * 2 -> Right
-     * 3 -> Top
-     * 4 -> Bottom
-     *
-     * @param edge
+     * @param edge Direction to scroll (see {@link MotionDir})
      * @return ViewAction
      */
     public static ViewAction scrollToEdge(final int edge) {
@@ -88,43 +89,13 @@ public class DetoxAction {
 
             @Override
             public void perform(UiController uiController, View view) {
-                Class<?> recyclerViewClass = null;
                 try {
-                    recyclerViewClass = Class.forName(RecyclerViewScrollListener.CLASS_RECYCLERVIEW);
-                } catch (ClassNotFoundException e) {
-                    // ok
-                }
-                if (view instanceof AbsListView) {
-                    RNScrollListener l = new RNScrollListener((AbsListView) view);
-                    do {
+                    for (int i = 0; i < 100; i++) {
                         ScrollHelper.performOnce(uiController, view, edge);
-                    } while (l.didScroll());
-                    l.cleanup();
-                } else if (view instanceof ScrollView) {
-                    int prevScrollY = view.getScrollY();
-                    while (true) {
-                        ScrollHelper.performOnce(uiController, view, edge);
-                        int currentScrollY = view.getScrollY();
-                        if (currentScrollY == prevScrollY) break;
-                        prevScrollY = currentScrollY;
                     }
-                } else if (view instanceof HorizontalScrollView) {
-                    int prevScrollX = view.getScrollX();
-                    while (true) {
-                        ScrollHelper.performOnce(uiController, view, edge);
-                        int currentScrollX = view.getScrollX();
-                        if (currentScrollX == prevScrollX) break;
-                        prevScrollX = currentScrollX;
-                    }
-                } else if (recyclerViewClass != null && recyclerViewClass.isInstance(view)) {
-                    RecyclerViewScrollListener l = new RecyclerViewScrollListener(view);
-                    do {
-                        ScrollHelper.performOnce(uiController, view, edge);
-                    } while (l.didScroll());
-                    l.cleanup();
-                } else {
-                    throw new RuntimeException(
-                            "Only descendants of AbsListView, ScrollView, HorizontalScrollView and RecyclerView are supported");
+                    throw new DetoxRuntimeException("Scrolling a lot without reaching the edge: force-breaking the loop");
+                } catch (ScrollEdgeException e) {
+                    // Done
                 }
             }
         });
@@ -133,15 +104,8 @@ public class DetoxAction {
     /**
      * Scrolls the View in a direction by the Density Independent Pixel amount.
      *
-     * Direction
-     * 1 -> left
-     * 2 -> Right
-     * 3 -> Up
-     * 4 -> Down
-     *
-     * @param direction Direction to scroll
+     * @param direction Direction to scroll (see {@link MotionDir})
      * @param amountInDP Density Independent Pixels
-     *
      */
     public static ViewAction scrollInDirection(final int direction, final double amountInDP) {
         return actionWithAssertions(new ViewAction() {
@@ -157,7 +121,43 @@ public class DetoxAction {
 
             @Override
             public void perform(UiController uiController, View view) {
-                ScrollHelper.perform(uiController, view, direction, amountInDP);
+                try {
+                    ScrollHelper.perform(uiController, view, direction, amountInDP);
+                } catch (Exception e) {
+                    throw new DetoxRuntimeException(e);
+                }
+            }
+        });
+    }
+
+    /**
+     * Scroll the view in a direction by a specified amount (DP units).
+     * <br/>Similar to {@link #scrollInDirection(int, double)}, but stops <b>gracefully</b> in the case
+     * where the scrolling-edge is reached, by throwing the {@link StaleActionException} exception (i.e.
+     * so as to make this use case manageable by the user).
+     *
+     * @param direction Direction to scroll (see {@link MotionDir})
+     * @param amountInDP Density Independent Pixels
+     */
+    public static ViewAction scrollInDirectionStaleAtEdge(final int direction, final double amountInDP) {
+        return actionWithAssertions(new ViewAction() {
+            @Override
+            public Matcher<View> getConstraints() {
+                return allOf(isAssignableFrom(View.class), isDisplayed());
+            }
+
+            @Override
+            public String getDescription() {
+                return "scrollInDirectionStaleAtEdge";
+            }
+
+            @Override
+            public void perform(UiController uiController, View view) {
+                try {
+                    ScrollHelper.perform(uiController, view, direction, amountInDP);
+                } catch (ScrollEdgeException exScrollAtEdge) {
+                    throw new StaleActionException(exScrollAtEdge);
+                }
             }
         });
     }
@@ -167,45 +167,40 @@ public class DetoxAction {
     /**
      * Swipes the View in a direction.
      *
-     * Direction
-     * 1 -> left
-     * 2 -> Right
-     * 3 -> Up
-     * 4 -> Down
-     *
-     * @param direction Direction to scroll
+     * @param direction Direction to swipe (see {@link MotionDir})
      * @param fast true if fast, false if slow
      *
      */
     public static ViewAction swipeInDirection(final int direction, boolean fast) {
         if (fast) {
             switch (direction) {
-                case 1:
+                case MOTION_DIR_LEFT:
                     return swipeLeft();
-                case 2:
+                case MOTION_DIR_RIGHT:
                     return swipeRight();
-                case 3:
+                case MOTION_DIR_UP:
                     return swipeUp();
-                case 4:
+                case MOTION_DIR_DOWN:
                     return swipeDown();
                 default:
                     throw new RuntimeException("Unsupported swipe direction: " + direction);
             }
         }
+
         switch (direction) {
-            case 1:
+            case MOTION_DIR_LEFT:
                 return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
                         translate(GeneralLocation.CENTER_RIGHT, -EDGE_FUZZ_FACTOR, 0),
                         GeneralLocation.CENTER_LEFT, Press.FINGER));
-            case 2:
+            case MOTION_DIR_RIGHT:
                 return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
                         translate(GeneralLocation.CENTER_LEFT, EDGE_FUZZ_FACTOR, 0),
                         GeneralLocation.CENTER_RIGHT, Press.FINGER));
-            case 3:
+            case MOTION_DIR_UP:
                 return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
                         translate(GeneralLocation.BOTTOM_CENTER, 0, -EDGE_FUZZ_FACTOR),
                         GeneralLocation.TOP_CENTER, Press.FINGER));
-            case 4:
+            case MOTION_DIR_DOWN:
                 return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
                         translate(GeneralLocation.TOP_CENTER, 0, EDGE_FUZZ_FACTOR),
                         GeneralLocation.BOTTOM_CENTER, Press.FINGER));
@@ -226,5 +221,4 @@ public class DetoxAction {
             }
         };
     }
-
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAssertion.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAssertion.java
index ef8f7d152..150e4a14b 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAssertion.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAssertion.java
@@ -2,6 +2,8 @@ package com.wix.detox.espresso;
 
 import android.view.View;
 
+import com.wix.detox.espresso.DetoxErrors.StaleActionException;
+
 import junit.framework.AssertionFailedError;
 
 import org.hamcrest.Matcher;
@@ -71,17 +73,22 @@ public class DetoxAssertion {
 
     public static void waitForAssertMatcherWithSearchAction(
             final ViewInteraction i,
-            final Matcher<View> m,
+            final Matcher<View> vm,
             final ViewAction searchAction,
             final Matcher<View> searchMatcher) {
 
         while (true) {
             try {
-                i.check(matches(m));
+                assertMatcher(i, vm);
                 break;
             } catch (Exception e) {
                 if (e instanceof EspressoException) {
-                    onView(searchMatcher).perform(searchAction);
+                    try {
+                        onView(searchMatcher).perform(searchAction);
+                    } catch (StaleActionException exStaleAction) {
+                        assertMatcher(i, vm);
+                        break;
+                    }
                 } else {
                     throw e;
                 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxErrors.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxErrors.java
new file mode 100644
index 000000000..b5087ac31
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxErrors.java
@@ -0,0 +1,23 @@
+package com.wix.detox.espresso;
+
+interface DetoxErrors {
+    class DetoxRuntimeException extends RuntimeException {
+        DetoxRuntimeException(Throwable cause) {
+            super(cause);
+        }
+
+        DetoxRuntimeException(String message) {
+            super(message);
+        }
+    }
+
+    /**
+     * Thrown when a Detox action has met conditions where it can no longer have an effect. For
+     * example, scrolling a view when it's already at the scrollable limit.
+     */
+    class StaleActionException extends DetoxRuntimeException {
+        StaleActionException(Throwable cause) {
+            super(cause);
+        }
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/RNScrollListener.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/RNScrollListener.java
deleted file mode 100644
index d2d0b23d8..000000000
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/RNScrollListener.java
+++ /dev/null
@@ -1,66 +0,0 @@
-package com.wix.detox.espresso;
-
-import android.support.annotation.NonNull;
-import android.util.Log;
-import android.widget.AbsListView;
-
-import org.joor.Reflect;
-
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/**
- * Created by simonracz on 09/08/2017.
- */
-
-public class RNScrollListener implements AbsListView.OnScrollListener {
-    private final static String LOG_TAG = "detox";
-    private static final String FIELD_SCROLL_LISTENER = "mOnScrollListener";
-
-    private AbsListView.OnScrollListener savedListener = null;
-    private AbsListView listView;
-
-    public AtomicBoolean scrolled = new AtomicBoolean(false);
-
-    /**
-     * Returns whether the AbsListview has scrolled since the last time this
-     * method was called.
-     *
-     * It resets the scrolled state on each call.
-     *
-     * @return scrolled
-     */
-    public boolean didScroll() {
-        boolean ret = scrolled.getAndSet(false);
-        return ret;
-    }
-
-    /**
-     * Call this method to remove the hook from the AbsListView and
-     * reset the scrollListener to the original listener.
-     */
-    public void cleanup() {
-        listView.setOnScrollListener(savedListener);
-    }
-
-    public RNScrollListener(@NonNull AbsListView listView) {
-        this.listView = listView;
-        savedListener = Reflect.on(listView).field(FIELD_SCROLL_LISTENER).get();
-        listView.setOnScrollListener(this);
-    }
-
-    @Override
-    public void onScrollStateChanged(AbsListView view, int scrollState) {
-        if (savedListener != null) {
-            savedListener.onScrollStateChanged(view, scrollState);
-        }
-    }
-
-    @Override
-    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
-        Log.d(LOG_TAG, "onScroll called");
-        scrolled.set(true);
-        if (savedListener != null) {
-            savedListener.onScroll(view, firstVisibleItem, visibleItemCount, totalItemCount);
-        }
-    }
-}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/RecyclerViewScrollListener.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/RecyclerViewScrollListener.java
deleted file mode 100644
index fda06ed97..000000000
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/RecyclerViewScrollListener.java
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.wix.detox.espresso;
-
-/**
- * Created by simonracz on 14/08/2017.
- */
-
-import android.support.annotation.NonNull;
-
-import com.wix.detox.Delegator;
-
-import org.joor.Reflect;
-
-import java.lang.reflect.Proxy;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/**
- * ScrollListener for RecyclerViews.
- *
- * As we do not statically link to a RecyclerView lib. This class
- * doesn't implement the interface directly. We use a Proxy class to
- * hook up to scroll listener methods with reflection.
- */
-public class RecyclerViewScrollListener {
-    private static final String LOG_TAG = "detox";
-
-    public static final String CLASS_RECYCLERVIEW = "android.support.v7.widget.RecyclerView";
-    private static final String CLASS_SCROLL_LISTENER = "android.support.v7.widget.RecyclerView.OnScrollListener";
-    private static final String METHOD_ADD_LISTENER = "addOnScrollListener";
-    private static final String METHOD_REMOVE_LISTENER = "removeOnScrollListener";
-
-    private Object recyclerView;
-
-    private AtomicBoolean scrolled = new AtomicBoolean(false);
-
-    RecyclerViewScrollListener(@NonNull Object recyclerView) {
-        this.recyclerView = recyclerView;
-        setUp();
-    }
-
-    private static Object proxyListener = null;
-
-    private void setUp() {
-        Class<?> listener = null;
-        try {
-            listener = Class.forName(CLASS_SCROLL_LISTENER);
-        } catch (ClassNotFoundException e) {
-            throw new RuntimeException("Cant find ScrollListener interface on RecyclerView", e);
-        }
-
-        Class[] proxyInterfaces = new Class[]{listener};
-        proxyListener = Proxy.newProxyInstance(
-                listener.getClassLoader(),
-                proxyInterfaces,
-                new Delegator(proxyInterfaces, new Object[] { this })
-        );
-
-        Reflect.on(recyclerView).call(METHOD_ADD_LISTENER, proxyListener);
-    }
-
-    /**
-     * Returns whether the RecyclerView has scrolled since the last time this
-     * method was called.
-     *
-     * It resets the scrolled state on each call.
-     *
-     * @return scrolled
-     */
-    public boolean didScroll() {
-        boolean ret = scrolled.getAndSet(false);
-        return ret;
-    }
-
-    /**
-     * Call this method to remove the hook from the RecyclerView.
-     */
-    public void cleanup() {
-        Reflect.on(recyclerView).call(METHOD_REMOVE_LISTENER, proxyListener);
-    }
-
-    //Proxy calls it
-    public void onScrollStateChanged(Object recyclerView, int newState) {
-        // empty
-    }
-
-    //Proxy calls it
-    public void onScrolled(Object recyclerView, int dx, int dy) {
-        scrolled.set(true);
-    }
-
-}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/common/annot/MotionDefs.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/common/annot/MotionDefs.kt
new file mode 100644
index 000000000..db5eab8b1
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/common/annot/MotionDefs.kt
@@ -0,0 +1,20 @@
+package com.wix.detox.espresso.common.annot
+
+import androidx.annotation.IntDef
+
+@Retention(AnnotationRetention.SOURCE)
+@IntDef(MOTION_DIR_LEFT, MOTION_DIR_RIGHT, MOTION_DIR_UP, MOTION_DIR_DOWN)
+annotation class MotionDir
+
+const val MOTION_DIR_LEFT = 1
+const val MOTION_DIR_RIGHT = 2
+const val MOTION_DIR_UP = 3
+const val MOTION_DIR_DOWN = 4
+
+fun isHorizontal(@MotionDir direction: Int): Boolean {
+    return direction == MOTION_DIR_LEFT || direction == MOTION_DIR_RIGHT
+}
+
+fun isVertical(@MotionDir direction: Int): Boolean {
+    return direction == MOTION_DIR_DOWN || direction == MOTION_DIR_UP
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollEdgeException.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollEdgeException.kt
new file mode 100644
index 000000000..c5c8f9371
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollEdgeException.kt
@@ -0,0 +1,3 @@
+package com.wix.detox.espresso.scroll
+
+class ScrollEdgeException(message: String) : Exception(message)
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/ScrollHelper.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java
similarity index 71%
rename from detox/android/detox/src/main/java/com/wix/detox/espresso/ScrollHelper.java
rename to detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java
index ffe56a70c..bc1e156cc 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/ScrollHelper.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java
@@ -1,19 +1,29 @@
-package com.wix.detox.espresso;
+package com.wix.detox.espresso.scroll;
 
 import android.os.SystemClock;
 import android.util.Log;
 import android.view.MotionEvent;
 import android.view.View;
 
+import com.wix.detox.espresso.UiAutomatorHelper;
+import com.wix.detox.espresso.common.annot.MotionDir;
+
 import androidx.test.espresso.UiController;
 import androidx.test.espresso.action.MotionEvents;
 
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_DOWN;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_LEFT;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_RIGHT;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.MOTION_DIR_UP;
+import static com.wix.detox.espresso.common.annot.MotionDefsKt.isHorizontal;
+import static com.wix.detox.espresso.scroll.ScrollProbesKt.getScrollableProbe;
+
 /**
  * Created by simonracz on 09/08/2017.
  */
 
 public class ScrollHelper {
-    private static final String LOG_TAG = "detox";
+    private static final String LOG_TAG = "DetoxScrollHelper";
 
     private static final int SCROLL_STEPS = 50;
     private static final int SCROLL_DURATION_MS = 275;
@@ -27,17 +37,11 @@ public class ScrollHelper {
     /**
      * Scrolls the View in a direction by the Density Independent Pixel amount.
      *
-     * Direction
-     * 1 -> left
-     * 2 -> Right
-     * 3 -> Up
-     * 4 -> Down
-     *
-     * @param direction Direction to scroll
+     * @param direction Direction to scroll (see {@link MotionDir})
      * @param amountInDP Density Independent Pixels
      *
      */
-    public static void perform(UiController uiController, View view, int direction, double amountInDP) {
+    public static void perform(UiController uiController, View view, @MotionDir int direction, double amountInDP) throws ScrollEdgeException {
         int adjWidth = 0;
         int adjHeight = 0;
 
@@ -48,23 +52,21 @@ public class ScrollHelper {
 
         float[] screenSize = UiAutomatorHelper.getScreenSizeInPX();
 
-        if (direction == 1) {
+        if (direction == MOTION_DIR_LEFT) {
             adjWidth = (int) ((screenSize[0] - pos[0]) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
-        } else if (direction == 2) {
+        } else if (direction == MOTION_DIR_RIGHT) {
             adjWidth = (int) ((pos[0] + view.getWidth()) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
-        } else if (direction == 3) {
+        } else if (direction == MOTION_DIR_UP) {
             adjHeight = (int) ((screenSize[1] - pos[1]) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
         } else {
             adjHeight = (int) ((pos[1] + view.getHeight()) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
         }
 
-        // Log.d(LOG_TAG, "Amount in px: " + amountInPX);
-
         int times;
         int remainder;
         int fullAmount;
 
-        if (direction == 1 || direction == 2) {
+        if (isHorizontal(direction)) {
             times = amountInPX / adjWidth;
             remainder = amountInPX % adjWidth;
             fullAmount = adjWidth;
@@ -74,8 +76,6 @@ public class ScrollHelper {
             fullAmount = adjHeight;
         }
 
-        // Log.d(LOG_TAG, "Scroll times: " + times + " rem: " + remainder + " full: " +fullAmount);
-
         for (int i = 0; i < times; ++i) {
             doScroll(uiController, view, direction, fullAmount);
             uiController.loopMainThreadUntilIdle();
@@ -85,57 +85,63 @@ public class ScrollHelper {
         uiController.loopMainThreadUntilIdle();
     }
 
-    private static boolean doScroll(UiController uiController, View view, int direction, int amount) {
+    private static void doScroll(UiController uiController, View view, @MotionDir int direction, int amount) throws ScrollEdgeException {
         int[] pos = new int[2];
         view.getLocationInWindow(pos);
         int x = pos[0];
         int y = pos[1];
 
-        int downX = 0;
-        int downY = 0;
-        int upX = 0;
-        int upY = 0;
+        int downX;
+        int downY;
+        int upX;
+        int upY;
 
         int marginX = (int) (view.getWidth() * DEFAULT_DEADZONE_PERCENT);
         int marginY = (int) (view.getHeight() * DEFAULT_DEADZONE_PERCENT);
 
         switch (direction) {
-            case 2:
+            case MOTION_DIR_RIGHT:
                 downX = x + view.getWidth() - marginX;
                 downY = y + view.getHeight() / 2;
                 upX = downX - amount;
                 upY = y + view.getHeight() / 2;
                 break;
-            case 1:
+            case MOTION_DIR_LEFT:
                 downX = x + marginX;
                 downY = y + view.getHeight() / 2;
                 upX = downX + amount;
                 upY = y + view.getHeight() / 2;
                 break;
-            case 4:
+            case MOTION_DIR_DOWN:
                 downX = x + view.getWidth() / 2;
                 downY = y + view.getHeight() - marginY;
                 upX = x + view.getWidth() / 2;
                 upY = downY - amount;
                 break;
-            case 3:
+            case MOTION_DIR_UP:
                 downX = x + view.getWidth() / 2;
                 downY = y + marginY;
                 upX = x + view.getWidth() / 2;
                 upY = downY + amount;
                 break;
             default:
-                throw new RuntimeException("Scrolldirection can go from 1 to 4");
+                throw new RuntimeException("Scroll direction can go from 1 to 4");
         }
+
+        final ScrollableProbe scrollableProbe = getScrollableProbe(view, direction);
+        if (scrollableProbe.atScrollingEdge()) {
+            throw new ScrollEdgeException("View is already at the scrolling edge");
+        }
+
         // Log.d(LOG_TAG, "scroll downx: " + downX + " downy: " + downY + " upx: " + upX + " upy: " + upY);
-        return sendScrollEvent(uiController, downX, downY, upX, upY);
+        sendScrollEvent(uiController, downX, downY, upX, upY);
     }
 
-    private static boolean sendScrollEvent(UiController uiController, int downX, int downY, int upX, int upY) {
+    private static void sendScrollEvent(UiController uiController, int downX, int downY, int upX, int upY) {
         float[] startCoordinates = new float[]{downX, downY};
         float[] endCoordinates = new float[]{upX, upY};
         float[] precision = new float[]{16f, 16f};
-        float[][] steps = interpolate(startCoordinates, endCoordinates, SCROLL_STEPS);
+        float[][] steps = interpolate(startCoordinates, endCoordinates);
         final int delayBetweenMovements = SCROLL_DURATION_MS / steps.length;
 
         MotionEvent downEvent = MotionEvents.sendDown(uiController, startCoordinates, precision).down;
@@ -144,7 +150,7 @@ public class ScrollHelper {
                 if (!MotionEvents.sendMovement(uiController, downEvent, steps[i])) {
                     Log.e(LOG_TAG, "Injection of move event as part of the scroll failed. Sending cancel event.");
                     MotionEvents.sendCancel(uiController, downEvent);
-                    return false;
+                    return;
                 }
 
                 long desiredTime = downEvent.getDownTime() + delayBetweenMovements * i;
@@ -154,23 +160,23 @@ public class ScrollHelper {
                 }
             }
 
+            Log.d(LOG_TAG, "View's scroll completed!");
+
             if (!MotionEvents.sendUp(uiController, downEvent, endCoordinates)) {
                 Log.e(LOG_TAG, "Injection of up event as part of the scroll failed. Sending cancel event.");
                 MotionEvents.sendCancel(uiController, downEvent);
-                return false;
             }
         } finally {
             downEvent.recycle();
         }
-        return true;
     }
 
-    private static float[][] interpolate(float[] start, float[] end, int steps) {
-        float[][] res = new float[steps][2];
+    private static float[][] interpolate(float[] start, float[] end) {
+        float[][] res = new float[SCROLL_STEPS][2];
 
-        for (int i = 1; i < steps + 1; i++) {
-            res[i - 1][0] = start[0] + (end[0] - start[0]) * i / (steps + 2f);
-            res[i - 1][1] = start[1] + (end[1] - start[1]) * i / (steps + 2f);
+        for (int i = 1; i < SCROLL_STEPS + 1; i++) {
+            res[i - 1][0] = start[0] + (end[0] - start[0]) * i / (SCROLL_STEPS + 2f);
+            res[i - 1][1] = start[1] + (end[1] - start[1]) * i / (SCROLL_STEPS + 2f);
         }
 
         return res;
@@ -180,15 +186,9 @@ public class ScrollHelper {
      * Scrolls the View in a direction once by the maximum amount possible. (Till the edge
      * of the screen.)
      *
-     * Direction
-     * 1 -> left
-     * 2 -> Right
-     * 3 -> Up
-     * 4 -> Down
-     *
-     * @param direction Direction to scroll
+     * @param direction Direction to scroll (see {@link @MotionDir})
      */
-    public static void performOnce(UiController uiController, View view, int direction) {
+    public static void performOnce(UiController uiController, View view, @MotionDir int direction) throws ScrollEdgeException {
         int adjWidth = 0;
         int adjHeight = 0;
 
@@ -197,11 +197,11 @@ public class ScrollHelper {
 
         float[] screenSize = UiAutomatorHelper.getScreenSizeInPX();
 
-        if (direction == 1) {
+        if (direction == MOTION_DIR_LEFT) {
             adjWidth = (int) ((screenSize[0] - pos[0]) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
-        } else if (direction == 2) {
+        } else if (direction == MOTION_DIR_RIGHT) {
             adjWidth = (int) ((pos[0] + view.getWidth()) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
-        } else if (direction == 3) {
+        } else if (direction == MOTION_DIR_UP) {
             adjHeight = (int) ((screenSize[1] - pos[1]) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
         } else {
             adjHeight = (int) ((pos[1] + view.getHeight()) * (1 - 2 * DEFAULT_DEADZONE_PERCENT));
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollProbes.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollProbes.kt
new file mode 100644
index 000000000..64bb49700
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollProbes.kt
@@ -0,0 +1,68 @@
+package com.wix.detox.espresso.scroll
+
+import android.annotation.SuppressLint
+import android.view.View
+import android.widget.AbsListView
+import com.wix.detox.espresso.common.annot.*
+
+// TODO ViewPager?
+fun getScrollableProbe(view: View, @MotionDir direction: Int): ScrollableProbe {
+    if (view is AbsListView) {
+        @SuppressLint("SwitchIntDef")
+        when (direction) {
+            MOTION_DIR_UP -> return AbsListViewBack(view)
+            MOTION_DIR_DOWN -> return AbsListViewForward(view)
+        }
+    }
+
+    return when (direction) {
+        MOTION_DIR_LEFT -> ScrollableProbeHBack(view)
+        MOTION_DIR_RIGHT -> ScrollableProbeHForward(view)
+        MOTION_DIR_UP -> ScrollableProbeVBack(view)
+        MOTION_DIR_DOWN -> ScrollableProbeVForward(view)
+        else -> throw IllegalArgumentException("Invalid direction: $direction")
+    }
+}
+
+interface ScrollableProbe {
+    fun atScrollingEdge(): Boolean
+}
+
+private abstract class AbsScrollableProbe(val view: View) : ScrollableProbe
+
+private class ScrollableProbeHBack(view: View) : AbsScrollableProbe(view) {
+    override fun atScrollingEdge() = !view.canScrollHorizontally(-1)
+}
+
+private class ScrollableProbeHForward(view: View) : AbsScrollableProbe(view) {
+    override fun atScrollingEdge() = !view.canScrollHorizontally(1)
+}
+
+private class ScrollableProbeVBack(view: View) : AbsScrollableProbe(view) {
+    override fun atScrollingEdge() = !view.canScrollVertically(-1)
+}
+
+private class ScrollableProbeVForward(view: View) : AbsScrollableProbe(view) {
+    override fun atScrollingEdge() = !view.canScrollVertically(1)
+}
+
+private class AbsListViewBack(val view: AbsListView) : ScrollableProbe {
+    override fun atScrollingEdge(): Boolean {
+        // Ported from AbsListView#canScrollList() which isn't compatible with all API levels
+        val firstTop = view.getChildAt(0).top
+        val firstPosition = view.firstVisiblePosition
+        return firstPosition > 0 || firstTop < view.listPaddingTop
+    }
+}
+
+private class AbsListViewForward(val view: AbsListView) : ScrollableProbe {
+    override fun atScrollingEdge(): Boolean {
+        // Ported from AbsListView#canScrollList() which isn't compatible with all API levels
+        val itemsCount = view.count
+        val childCount = view.childCount
+        val firstPosition = view.firstVisiblePosition
+        val lastPosition = firstPosition + childCount
+        val lastBottom = view.getChildAt(childCount - 1).bottom
+        return (lastPosition < itemsCount) || (lastBottom > (view.height - view.listPaddingBottom))
+    }
+}
diff --git a/detox/src/android/espressoapi/DetoxAction.js b/detox/src/android/espressoapi/DetoxAction.js
index a041ecb1e..5cd5c1fcc 100644
--- a/detox/src/android/espressoapi/DetoxAction.js
+++ b/detox/src/android/espressoapi/DetoxAction.js
@@ -102,6 +102,25 @@ class DetoxAction {
     };
   }
 
+  static scrollInDirectionStaleAtEdge(direction, amountInDP) {
+    if (typeof direction !== "string") throw new Error("direction should be a string, but got " + (direction + (" (" + (typeof direction + ")"))));
+    if (typeof amountInDP !== "number") throw new Error("amountInDP should be a number, but got " + (amountInDP + (" (" + (typeof amountInDP + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.DetoxAction"
+      },
+      method: "scrollInDirectionStaleAtEdge",
+      args: [{
+        type: "Integer",
+        value: sanitize_android_direction(direction)
+      }, {
+        type: "Double",
+        value: amountInDP
+      }]
+    };
+  }
+
   static swipeInDirection(direction, fast) {
     if (typeof direction !== "string") throw new Error("direction should be a string, but got " + (direction + (" (" + (typeof direction + ")"))));
     if (typeof fast !== "boolean") throw new Error("fast should be a boolean, but got " + (fast + (" (" + (typeof fast + ")"))));
diff --git a/detox/src/android/espressoapi/DetoxAssertion.js b/detox/src/android/espressoapi/DetoxAssertion.js
index cd9903c9c..31a672b1d 100644
--- a/detox/src/android/espressoapi/DetoxAssertion.js
+++ b/detox/src/android/espressoapi/DetoxAssertion.js
@@ -80,7 +80,7 @@ class DetoxAssertion {
     };
   }
 
-  static waitForAssertMatcherWithSearchAction(i, m, searchAction, searchMatcher) {
+  static waitForAssertMatcherWithSearchAction(i, vm, searchAction, searchMatcher) {
     return {
       target: {
         type: "Class",
@@ -92,7 +92,7 @@ class DetoxAssertion {
         value: i
       }, {
         type: "Invocation",
-        value: sanitize_matcher(m)
+        value: sanitize_matcher(vm)
       }, searchAction, {
         type: "Invocation",
         value: sanitize_matcher(searchMatcher)
diff --git a/detox/src/android/expect.js b/detox/src/android/expect.js
index 30bb322b0..2a846ec11 100644
--- a/detox/src/android/expect.js
+++ b/detox/src/android/expect.js
@@ -89,6 +89,13 @@ class ScrollAmountAction extends Action {
   }
 }
 
+class ScrollAmountStopAtEdgeAction extends Action {
+  constructor(direction, amount) {
+    super();
+    this._call = invoke.callDirectly(DetoxActionApi.scrollInDirectionStaleAtEdge(direction, amount));
+  }
+}
+
 class ScrollEdgeAction extends Action {
   constructor(edge) {
     super();
@@ -113,6 +120,7 @@ class SwipeAction extends Action {
 
 class Interaction {
   constructor(invocationManager) {
+    this._call = undefined;
     this._invocationManager = invocationManager;
   }
 
@@ -159,7 +167,7 @@ class WaitForInteraction extends Interaction {
   }
 }
 
-class WaitForActionInteraction extends Interaction {
+class WaitForActionInteractionBase extends Interaction {
   constructor(invocationManager, element, matcher, searchMatcher) {
     super(invocationManager);
     //if (!(element instanceof Element)) throw new Error(`WaitForActionInteraction ctor 1st argument must be a valid Element, got ${typeof element}`);
@@ -170,7 +178,8 @@ class WaitForActionInteraction extends Interaction {
     this._originalMatcher = matcher;
     this._searchMatcher = searchMatcher;
   }
-  async _execute(searchAction) {
+
+  _prepare(searchAction) {
     //if (!searchAction instanceof Action) throw new Error(`WaitForActionInteraction _execute argument must be a valid Action, got ${typeof searchAction}`);
 
     this._call = DetoxAssertionApi.waitForAssertMatcherWithSearchAction(
@@ -179,11 +188,13 @@ class WaitForActionInteraction extends Interaction {
       call(searchAction._call),
       call(this._searchMatcher._call).value
     );
-
-    await this.execute();
   }
+}
+
+class WaitForActionInteraction extends WaitForActionInteractionBase {
   async scroll(amount, direction = 'down') {
-    await this._execute(new ScrollAmountAction(direction, amount));
+    this._prepare(new ScrollAmountStopAtEdgeAction(direction, amount));
+    await this.execute();
   }
 }
 
diff --git a/detox/src/client/actions/actions.js b/detox/src/client/actions/actions.js
index c3f761f0c..1d060cb46 100644
--- a/detox/src/client/actions/actions.js
+++ b/detox/src/client/actions/actions.js
@@ -83,14 +83,14 @@ class ReloadReactNative extends Action {
 
 class Cleanup extends Action {
   constructor(stopRunner) {
-    const params = {
-      stopRunner: stopRunner
-    };
-    super('cleanup', params);
+    super('cleanup', { stopRunner });
+    this.messageId = -0xc1ea;
   }
 
   async handle(response) {
-    this.expectResponseOfType(response, 'cleanupDone');
+    if (response.type !== 'testeeDisconnected') {
+      this.expectResponseOfType('cleanupDone');
+    }
   }
 }
 
diff --git a/detox/src/server/DetoxServer.js b/detox/src/server/DetoxServer.js
index 7f748502c..68fd47a0a 100644
--- a/detox/src/server/DetoxServer.js
+++ b/detox/src/server/DetoxServer.js
@@ -49,6 +49,10 @@ class DetoxServer {
         if (sessionId && role) {
           this.log.debug({ event: 'DISCONNECT' }, `role=${role}, sessionId=${sessionId}`);
 
+          if (role === ROLE_TESTEE) {
+            this.sendToOtherRole(sessionId, role, { type: 'testeeDisconnected', messageId: -0xc1ea });
+          }
+
           if (this.standalone && role === ROLE_TESTER) {
             this.sendToOtherRole(sessionId, role, { type: 'testerDisconnected', messageId: -1 });
           }
@@ -70,6 +74,13 @@ class DetoxServer {
       this.sendAction(ws, action);
     } else {
       this.log.debug({ event: 'CANNOT_FORWARD' }, `role=${otherRole} not connected, cannot fw action (sessionId=${sessionId})`);
+
+      if (role === ROLE_TESTER && action.type === 'cleanup') {
+        this.sendToOtherRole(sessionId, otherRole, {
+          type: 'testeeDisconnected',
+          messageId: action.messageId,
+        });
+      }
     }
   }
 
diff --git a/generation/adapters/android.js b/generation/adapters/android.js
index 47936411f..367c84ec7 100644
--- a/generation/adapters/android.js
+++ b/generation/adapters/android.js
@@ -21,6 +21,12 @@ const contentSanitizersForFunction = {
     name: 'sanitize_android_direction',
     value: callGlobal('sanitize_android_direction')
   },
+  scrollInDirectionStaleAtEdge: {
+    argumentName: 'direction',
+    newType: 'String',
+    name: 'sanitize_android_direction',
+    value: callGlobal('sanitize_android_direction')
+  },
   swipeInDirection: {
     argumentName: 'direction',
     newType: 'String',
diff --git a/generation/index.js b/generation/index.js
index 6fab53872..4b933271c 100755
--- a/generation/index.js
+++ b/generation/index.js
@@ -50,8 +50,8 @@ const androidFiles = {
   ...downloadedAndroidFilesMap,
   ...downloadedEspressoFilesMap,
   '../detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java': '../detox/src/android/espressoapi/DetoxAction.js',
-  '../detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxViewActions.java':
-    '../detox/src/android/espressoapi/DetoxViewActions.js',
+  '../detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAssertion.java': '../detox/src/android/espressoapi/DetoxAssertion.js',
+  '../detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxViewActions.java': '../detox/src/android/espressoapi/DetoxViewActions.js',
   '../detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxMatcher.java': '../detox/src/android/espressoapi/DetoxMatcher.js',
   '../detox/android/detox/src/main/java/com/wix/detox/Detox.java': '../detox/src/android/espressoapi/Detox.js',
   '../detox/android/detox/src/main/java/com/wix/detox/espresso/EspressoDetox.java': '../detox/src/android/espressoapi/EspressoDetox.js',


Test patch:
diff --git a/detox/src/client/Client.test.js b/detox/src/client/Client.test.js
index 709e53109..d15c69262 100644
--- a/detox/src/client/Client.test.js
+++ b/detox/src/client/Client.test.js
@@ -110,6 +110,16 @@ describe('Client', () => {
     expect(client.ws.send).toHaveBeenCalledTimes(3);
   });
 
+  it(`cleanup() - if connected should accept testeeDisconnected action too`, async () => {
+    await connect();
+    client.ws.send.mockReturnValueOnce(response("ready", {}, 1));
+    await client.waitUntilReady();
+    client.ws.send.mockReturnValueOnce(response("testeeDisconnected", {}, 2));
+    await client.cleanup();
+
+    expect(client.ws.send).toHaveBeenCalledTimes(3);
+  });
+
   it(`cleanup() - if not connected should do nothing`, async () => {
     client = new Client(config);
     client.ws.send.mockReturnValueOnce(response("cleanupDone", {}, 1));
diff --git a/detox/test/e2e/03.fs-scroll-actions.test.js b/detox/test/e2e/03.fs-scroll-actions.test.js
index d6301aef7..8bbbb2157 100644
--- a/detox/test/e2e/03.fs-scroll-actions.test.js
+++ b/detox/test/e2e/03.fs-scroll-actions.test.js
@@ -6,52 +6,63 @@
  * is valid nevertheless.
  */
 
-describe('Fullscreen scrolling Actions', () => {
+const {expectToThrow} = require('./utils/custom-expects');
+
+describe('Fullscreen scroll-view', () => {
   beforeEach(async () => {
     await device.reloadReactNative();
     await element(by.text('Scroll-Actions')).tap();
   });
 
-  it('should scroll for a small amount in direction', async () => {
-    await expect(scrollViewDriver.element()).toBeVisible();
-    await expect(scrollViewDriver.firstItem()).toBeVisible();
-    await expect(scrollViewDriver.lastItem()).toBeNotVisible();
-
-    await scrollViewDriver.scrollBy(60);
-    await expect(scrollViewDriver.firstItem()).toBeNotVisible();
-    await expect(scrollViewDriver.secondItem()).toBeVisible();
-    await scrollViewDriver.scrollBy(-60);
-    await expect(scrollViewDriver.firstItem()).toBeVisible();
-    await expect(scrollViewDriver.lastItem()).toBeNotVisible();
-  });
+  describe('actions', () => {
+    it('should scroll for a small amount in direction', async () => {
+      await expect(scrollViewDriver.element()).toBeVisible();
+      await expect(scrollViewDriver.firstItem()).toBeVisible();
+      await expect(scrollViewDriver.lastItem()).toBeNotVisible();
+
+      await scrollViewDriver.scrollBy(60);
+      await expect(scrollViewDriver.firstItem()).toBeNotVisible();
+      await expect(scrollViewDriver.secondItem()).toBeVisible();
+      await scrollViewDriver.scrollBy(-60);
+      await expect(scrollViewDriver.firstItem()).toBeVisible();
+      await expect(scrollViewDriver.lastItem()).toBeNotVisible();
+    });
 
-  it('should scroll for a large amount in direction', async () => {
-    await expect(scrollViewDriver.element()).toBeVisible();
-    await expect(scrollViewDriver.firstItem()).toBeVisible();
-    await expect(scrollViewDriver.lastItem()).toBeNotVisible();
-
-    try {
-      await scrollViewDriver.scrollBy(1000);
-    } catch (error) {
-      console.log('Expected error caught: Scrolled the list down to its very end');
-    }
-    await expect(scrollViewDriver.firstItem()).toBeNotVisible();
-    await expect(scrollViewDriver.lastItem()).toBeVisible();
+    it('should scroll for a large amount in direction', async () => {
+      await expect(scrollViewDriver.element()).toBeVisible();
+      await expect(scrollViewDriver.firstItem()).toBeVisible();
+      await expect(scrollViewDriver.lastItem()).toBeNotVisible();
+
+      await expectToThrow(() => scrollViewDriver.scrollBy(1000));
+
+      await expect(scrollViewDriver.firstItem()).toBeNotVisible();
+      await expect(scrollViewDriver.lastItem()).toBeVisible();
+    });
   });
 
-  it('should find element by scrolling until it is visible', async () => {
-    await expect(scrollViewDriver.lastItem()).toBeNotVisible();
-    await waitFor(scrollViewDriver.lastItem()).toBeVisible().whileElement(scrollViewDriver.viewFilter()).scroll(200, 'down');
-    await expect(scrollViewDriver.lastItem()).toBeVisible();
+  describe('waitFor()', () => {
+    it('should find element by scrolling until it is visible', async () => {
+      await expect(scrollViewDriver.lastItem()).toBeNotVisible();
+      await waitFor(scrollViewDriver.lastItem()).toBeVisible().whileElement(scrollViewDriver.byId()).scroll(200, 'down');
+      await expect(scrollViewDriver.lastItem()).toBeVisible();
+    });
+
+    it('should abort scrolling if element was not found', async () => {
+      await expect(scrollViewDriver.fakeItem()).toBeNotVisible();
+      await expectToThrow(() => waitFor(scrollViewDriver.fakeItem()).toBeVisible().whileElement(scrollViewDriver.byId()).scroll(1000, 'down'));
+      await expect(scrollViewDriver.fakeItem()).toBeNotVisible();
+      await expect(scrollViewDriver.lastItem()).toBeVisible();
+    });
   });
 });
 
 const scrollViewDriver = {
-  viewFilter: () => by.id('FSScrollActions.scrollView'),
-  element: () => element(scrollViewDriver.viewFilter()),
+  byId: () => by.id('FSScrollActions.scrollView'),
+  element: () => element(scrollViewDriver.byId()),
   listItem: (index) => element(by.text(`Text${index}`)),
   firstItem: () => scrollViewDriver.listItem(1),
   secondItem: () => scrollViewDriver.listItem(2),
   lastItem: () => scrollViewDriver.listItem(20),
+  fakeItem: () => scrollViewDriver.listItem(1000),
   scrollBy: (amount) => scrollViewDriver.element().scroll(Math.abs(amount), (amount > 0 ? 'down' : 'up')),
 };
diff --git a/detox/test/e2e/05.waitfor.test.js b/detox/test/e2e/05.waitfor.test.js
index 8ca0ff82b..cc73e8818 100644
--- a/detox/test/e2e/05.waitfor.test.js
+++ b/detox/test/e2e/05.waitfor.test.js
@@ -1,4 +1,5 @@
 const custom = require('./utils/custom-it');
+const {expectToThrow} = require('./utils/custom-expects');
 
 describe('WaitFor', () => {
   beforeEach(async() => {
@@ -26,4 +27,10 @@ describe('WaitFor', () => {
     await waitFor(element(by.text('Text5'))).toBeVisible().whileElement(by.id('ScrollView')).scroll(50, 'down');
     await expect(element(by.text('Text5'))).toBeVisible();
   });
+
+  it('should abort scrolling if element was not found', async () => {
+    await element(by.id('GoButton')).tap();
+    await expectToThrow(() => waitFor(element(by.text('Text1000'))).toBeVisible().whileElement(by.id('ScrollView')).scroll(50, 'down'));
+    await expect(element(by.text('Text1000'))).toBeNotVisible();
+  });
 });
diff --git a/detox/test/e2e/23.flows.test.js b/detox/test/e2e/23.flows.test.js
new file mode 100644
index 000000000..2b6351e3d
--- /dev/null
+++ b/detox/test/e2e/23.flows.test.js
@@ -0,0 +1,6 @@
+describe('Flows', () => {
+  it('should exit without timeouts if app was terminated inside test', async () => {
+    await device.launchApp();
+    await device.terminateApp();
+  });
+});
diff --git a/detox/test/e2e/utils/custom-expects.js b/detox/test/e2e/utils/custom-expects.js
new file mode 100644
index 000000000..4f67f10c8
--- /dev/null
+++ b/detox/test/e2e/utils/custom-expects.js
@@ -0,0 +1,18 @@
+async function expectToThrow(testBlock) {
+
+  let error;
+  try {
+    await testBlock();
+  } catch (e) {
+    error = e;
+    console.log('Caught an expected error:', e.message.split('\n', 1)[0]);
+  }
+
+  if (!error) {
+    throw new Error('Expected an error but nothing was thrown');
+  }
+}
+
+module.exports = {
+  expectToThrow,
+};