Repository: wix/Detox
Pull request #1440 (2019-06-11 19:16:12)

Updating Guide.Mocking with Metro breaking changes
- [x] This change has been discussed in issue #1420 and the solution has been agreed upon with maintainers.

Updating Guide.Mocking with Metro breaking changes


rotemmiz: Can you please add the corresponding react native version that uses metro 0.43?

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 36f7c5f2b..599eb3a35 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,44 @@
 # Change Log
 
+## [12.11.2](https://github.com/wix/Detox/tree/12.11.2) (2019-06-21)
+[Full Changelog](https://github.com/wix/Detox/compare/12.11.1...12.11.2)
+
+**Enhancements**
+
+- Update EarlGrey to include TrustKit fixes [\#1392](https://github.com/wix/Detox/issues/1392)
+- \(Android\) Introduce exception-based, edge-monitoring in scrolling actions of all flavours \(equiv to iOS\) [\#1464](https://github.com/wix/Detox/pull/1464) ([d4vidi](https://github.com/d4vidi))
+- refactor: allow technically to control multiple detox instances  [\#1144](https://github.com/wix/Detox/pull/1144) ([manuquentin](https://github.com/manuquentin))
+
+**Fixed Bugs**
+
+- bug: timeout error on detox.cleanup\(\) if an app had been terminated earlier in a test [\#1460](https://github.com/wix/Detox/issues/1460)
+- \(Android\) Endless scrolling attempts if item not found using `waitFor` [\#1409](https://github.com/wix/Detox/issues/1409)
+
+**Closed Issues**
+
+- Second Test File Causing Issues [\#1458](https://github.com/wix/Detox/issues/1458)
+- Detox hangs on "Trying permissions" on CircleCI [\#1457](https://github.com/wix/Detox/issues/1457)
+- `invocationManager` is cached by nodejs' require cache and prevent multiple client [\#1119](https://github.com/wix/Detox/issues/1119)
+
+**Merged Pull Requests**
+
+- fix\(ws\): explicit error handling [\#1467](https://github.com/wix/Detox/pull/1467) ([noomorph](https://github.com/noomorph))
+- fix: conduct cleanup even if app has been already terminated [\#1465](https://github.com/wix/Detox/pull/1465) ([noomorph](https://github.com/noomorph))
+- Revive generation script [\#1459](https://github.com/wix/Detox/pull/1459) ([d4vidi](https://github.com/d4vidi))
+- Increase log-level for result of 'adb devices' command [\#1453](https://github.com/wix/Detox/pull/1453) ([d4vidi](https://github.com/d4vidi))
+
+## [12.11.1](https://github.com/wix/Detox/tree/12.11.1) (2019-06-14)
+[Full Changelog](https://github.com/wix/Detox/compare/12.11.0...12.11.1)
+
+**Closed Issues**
+
+- Regex For Matchers [\#1441](https://github.com/wix/Detox/issues/1441)
+
+**Merged Pull Requests**
+
+- use --byId instead of --simulator [\#1448](https://github.com/wix/Detox/pull/1448) ([vonovak](https://github.com/vonovak))
+- Update APIRef.ActionsOnElement.md [\#1437](https://github.com/wix/Detox/pull/1437) ([leotm](https://github.com/leotm))
+
 ## [12.11.0](https://github.com/wix/Detox/tree/12.11.0) (2019-06-07)
 [Full Changelog](https://github.com/wix/Detox/compare/12.10.3...12.11.0)
 
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/package.json b/detox/package.json
index 4c7207e17..21e132937 100644
--- a/detox/package.json
+++ b/detox/package.json
@@ -1,7 +1,7 @@
 {
   "name": "detox",
   "description": "E2E tests and automation for mobile",
-  "version": "12.11.0",
+  "version": "12.11.2",
   "bin": {
     "detox": "local-cli/cli.js"
   },
@@ -44,6 +44,7 @@
     "chalk": "^2.4.2",
     "child-process-promise": "^2.2.0",
     "fs-extra": "^4.0.2",
+    "funpermaproxy": "^1.0.1",
     "get-port": "^2.1.0",
     "ini": "^1.3.4",
     "lodash": "^4.17.5",
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index bac455f33..2045f71f3 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -24,11 +24,13 @@ const DEVICE_CLASSES = {
 
 class Detox {
   constructor({deviceConfig, session}) {
-    this.deviceConfig = deviceConfig;
-    this.userSession = deviceConfig.session || session;
-    this.client = null;
+    this._deviceConfig = deviceConfig;
+    this._userSession = deviceConfig.session || session;
+    this._client = null;
+    this._server = null;
+    this._artifactsManager = new ArtifactsManager();
+
     this.device = null;
-    this.artifactsManager = new ArtifactsManager();
   }
 
   async init(userParams) {
@@ -39,57 +41,64 @@ class Detox {
       ...userParams,
     };
 
-    if (!this.userSession) {
-      this.server = new DetoxServer({
+    if (!this._userSession) {
+      this._server = new DetoxServer({
         log: logger,
         port: new URL(sessionConfig.server).port,
       });
     }
 
-    this.client = new Client(sessionConfig);
-    await this.client.connect();
+    this._client = new Client(sessionConfig);
+    await this._client.connect();
 
-    const DeviceDriverClass = DEVICE_CLASSES[this.deviceConfig.type];
+    const DeviceDriverClass = DEVICE_CLASSES[this._deviceConfig.type];
     if (!DeviceDriverClass) {
-      throw new Error(`'${this.deviceConfig.type}' is not supported`);
+      throw new Error(`'${this._deviceConfig.type}' is not supported`);
     }
 
     const deviceDriver = new DeviceDriverClass({
-      client: this.client,
+      client: this._client,
     });
 
-    this.artifactsManager.subscribeToDeviceEvents(deviceDriver);
-    this.artifactsManager.registerArtifactPlugins(deviceDriver.declareArtifactPlugins());
+    this._artifactsManager.subscribeToDeviceEvents(deviceDriver);
+    this._artifactsManager.registerArtifactPlugins(deviceDriver.declareArtifactPlugins());
 
-    this.device = new Device({
-      deviceConfig: this.deviceConfig,
+    const device = new Device({
+      deviceConfig: this._deviceConfig,
       deviceDriver,
       sessionConfig,
     });
 
-    await this.device.prepare(params);
+    await device.prepare(params);
+
+    const globalsToExport = {
+      ...deviceDriver.matchers,
+      device,
+    };
 
+    Object.assign(this, globalsToExport);
     if (params.initGlobals) {
-      deviceDriver.exportGlobals();
-      global.device = this.device;
+      Object.assign(global, globalsToExport);
     }
 
-    await this.artifactsManager.onBeforeAll();
+    await this._artifactsManager.onBeforeAll();
+
+    return this;
   }
 
   async cleanup() {
-    await this.artifactsManager.onAfterAll();
+    await this._artifactsManager.onAfterAll();
 
-    if (this.client) {
-      await this.client.cleanup();
+    if (this._client) {
+      await this._client.cleanup();
     }
 
     if (this.device) {
       await this.device._cleanup();
     }
 
-    if (this.server) {
-      await this.server.close();
+    if (this._server) {
+      await this._server.close();
     }
 
     if (argparse.getArgValue('cleanup') && this.device) {
@@ -101,13 +110,13 @@ class Detox {
     this._validateTestSummary(testSummary);
     this._logTestRunCheckpoint('DETOX_BEFORE_EACH', testSummary);
     await this._handleAppCrashIfAny(testSummary.fullName);
-    await this.artifactsManager.onBeforeEach(testSummary);
+    await this._artifactsManager.onBeforeEach(testSummary);
   }
 
   async afterEach(testSummary) {
     this._validateTestSummary(testSummary);
     this._logTestRunCheckpoint('DETOX_AFTER_EACH', testSummary);
-    await this.artifactsManager.onAfterEach(testSummary);
+    await this._artifactsManager.onAfterEach(testSummary);
     await this._handleAppCrashIfAny(testSummary.fullName);
   }
 
@@ -142,7 +151,7 @@ class Detox {
   }
 
   async _handleAppCrashIfAny(testName) {
-    const pendingAppCrash = this.client.getPendingCrashAndReset();
+    const pendingAppCrash = this._client.getPendingCrashAndReset();
 
     if (pendingAppCrash) {
       log.error({ event: 'APP_CRASH' }, `App crashed in test '${testName}', here's the native stack trace: \n${pendingAppCrash}`);
@@ -151,7 +160,7 @@ class Detox {
   }
 
   async _getSessionConfig() {
-    const session = this.userSession || await configuration.defaultSession();
+    const session = this._userSession || await configuration.defaultSession();
 
     configuration.validateSession(session);
 
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
new file mode 100644
index 000000000..3a021cbb8
--- /dev/null
+++ b/detox/src/DetoxExportWrapper.js
@@ -0,0 +1,114 @@
+const _ = require('lodash');
+const funpermaproxy = require('funpermaproxy');
+const Detox = require('./Detox');
+const DetoxConstants = require('./DetoxConstants');
+const argparse = require('./utils/argparse');
+const log = require('./utils/logger').child({ __filename });
+const configuration = require('./configuration');
+
+const _detox = Symbol('detox');
+
+class DetoxExportWrapper {
+  constructor() {
+    this[_detox] = null;
+
+    this.init = this.init.bind(this);
+    this.cleanup = this.cleanup.bind(this);
+
+    this.DetoxConstants = DetoxConstants;
+
+    this._definePassthroughMethod('beforeEach');
+    this._definePassthroughMethod('afterEach');
+
+    this._definePassthroughMethod('element');
+    this._definePassthroughMethod('expect');
+    this._definePassthroughMethod('waitFor');
+
+    this._defineProxy('by');
+    this._defineProxy('device');
+  }
+
+  async init(config, params) {
+    this[_detox] = await DetoxExportWrapper._initializeInstance(config, params);
+    return this[_detox];
+  }
+
+  async cleanup() {
+    if (this[_detox]) {
+      await this[_detox].cleanup();
+      this[_detox] = null;
+    }
+  }
+
+  _definePassthroughMethod(name) {
+    this[name] = (...args) => {
+      if (this[_detox]) {
+        return this[_detox][name](...args);
+      }
+    };
+  }
+
+  _defineProxy(name) {
+    this[name] = funpermaproxy(() => (this[_detox] && this[_detox][name]));
+  }
+
+  static async _initializeInstance(detoxConfig, params) {
+    let instance = null;
+
+    try {
+      if (!detoxConfig) {
+        throw new Error(`No configuration was passed to detox, make sure you pass a detoxConfig when calling 'detox.init(detoxConfig)'`);
+      }
+
+      if (!(detoxConfig.configurations && _.size(detoxConfig.configurations) >= 1)) {
+        throw new Error(`There are no device configurations in the detox config`);
+      }
+
+      instance = new Detox({
+        deviceConfig: DetoxExportWrapper._getDeviceConfig(detoxConfig),
+        session: detoxConfig.session,
+      });
+
+      await instance.init(params);
+      return instance;
+    } catch (err) {
+      log.error({ event: 'DETOX_INIT_ERROR' }, '\n', err);
+
+      if (instance) {
+        await instance.cleanup();
+      }
+
+      throw err;
+    }
+  }
+
+  static _getDeviceConfig({ configurations }) {
+    const configurationName = argparse.getArgValue('configuration');
+    const deviceOverride = argparse.getArgValue('device-name');
+
+    const deviceConfig = (!configurationName && _.size(configurations) === 1)
+      ? _.values(configurations)[0]
+      : configurations[configurationName];
+
+    if (!deviceConfig) {
+      throw new Error(`Cannot determine which configuration to use. use --configuration to choose one of the following:
+                        ${Object.keys(configurations)}`);
+    }
+
+    if (deviceOverride) {
+      deviceConfig.name = deviceOverride;
+    }
+
+    if (!deviceConfig.type) {
+      configuration.throwOnEmptyType();
+    }
+
+    if (!deviceConfig.name) {
+      configuration.throwOnEmptyName();
+    }
+
+    return deviceConfig;
+  }
+}
+
+module.exports = DetoxExportWrapper;
diff --git a/detox/src/android/espressoapi/Detox.js b/detox/src/android/espressoapi/Detox.js
index 4547bf1cc..990d743dd 100644
--- a/detox/src/android/espressoapi/Detox.js
+++ b/detox/src/android/espressoapi/Detox.js
@@ -46,13 +46,24 @@ class Detox {
     };
   }
 
-  static extractInitialIntent() {
+  static extractLaunchIntent() {
     return {
       target: {
         type: "Class",
         value: "com.wix.detox.Detox"
       },
-      method: "extractInitialIntent",
+      method: "extractLaunchIntent",
+      args: []
+    };
+  }
+
+  static defaultIntent() {
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.Detox"
+      },
+      method: "defaultIntent",
       args: []
     };
   }
@@ -69,6 +80,17 @@ class Detox {
     };
   }
 
+  static readLaunchArgs() {
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.Detox"
+      },
+      method: "readLaunchArgs",
+      args: []
+    };
+  }
+
 }
 
 module.exports = Detox;
\ No newline at end of file
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 4781d3344..2a846ec11 100644
--- a/detox/src/android/expect.js
+++ b/detox/src/android/expect.js
@@ -18,14 +18,8 @@ const NotExistsMatcher = matchers.NotExistsMatcher;
 const TextMatcher = matchers.TextMatcher;
 const ValueMatcher = matchers.ValueMatcher;
 
-let invocationManager;
-
 const DetoxAssertion = 'com.wix.detox.espresso.DetoxAssertion';
 
-function setInvocationManager(im) {
-  invocationManager = im;
-}
-
 function call(maybeAFunction) {
   return maybeAFunction instanceof Function ? maybeAFunction() : maybeAFunction;
 }
@@ -95,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();
@@ -118,30 +119,35 @@ class SwipeAction extends Action {
 }
 
 class Interaction {
+  constructor(invocationManager) {
+    this._call = undefined;
+    this._invocationManager = invocationManager;
+  }
+
   async execute() {
-    await invocationManager.execute(this._call);
+    await this._invocationManager.execute(this._call);
   }
 }
 
 class ActionInteraction extends Interaction {
-  constructor(element, action) {
-    super();
+  constructor(invocationManager, element, action) {
+    super(invocationManager);
     this._call = EspressoDetoxApi.perform(call(element._call), action._call);
     // TODO: move this.execute() here from the caller
   }
 }
 
 class MatcherAssertionInteraction extends Interaction {
-  constructor(element, matcher) {
-    super();
+  constructor(invocationManager, element, matcher) {
+    super(invocationManager);
     this._call = DetoxAssertionApi.assertMatcher(call(element._call), matcher._call.value);
     // TODO: move this.execute() here from the caller
   }
 }
 
 class WaitForInteraction extends Interaction {
-  constructor(element, matcher) {
-    super();
+  constructor(invocationManager, element, matcher) {
+    super(invocationManager);
     this._element = element;
     this._originalMatcher = matcher;
     // we need to override the original matcher for the element and add matcher to it as well
@@ -157,13 +163,13 @@ class WaitForInteraction extends Interaction {
   }
 
   whileElement(searchMatcher) {
-    return new WaitForActionInteraction(this._element, this._originalMatcher, searchMatcher);
+    return new WaitForActionInteraction(this._invocationManager, this._element, this._originalMatcher, searchMatcher);
   }
 }
 
-class WaitForActionInteraction extends Interaction {
-  constructor(element, matcher, searchMatcher) {
-    super();
+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}`);
     //if (!(matcher instanceof Matcher)) throw new Error(`WaitForActionInteraction ctor 2nd argument must be a valid Matcher, got ${typeof matcher}`);
     if (!(searchMatcher instanceof Matcher))
@@ -172,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(
@@ -181,16 +188,19 @@ 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();
   }
 }
 
 class Element {
-  constructor(matcher) {
+  constructor(invocationManager, matcher) {
+    this._invocationManager = invocationManager;
     this._originalMatcher = matcher;
     this._selectElementWithMatcher(this._originalMatcher);
   }
@@ -208,149 +218,153 @@ class Element {
     return this;
   }
   async tap() {
-    return await new ActionInteraction(this, new TapAction()).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TapAction()).execute();
   }
   async tapAtPoint(value) {
-    return await new ActionInteraction(this, new TapAtPointAction(value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TapAtPointAction(value)).execute();
   }
   async longPress() {
-    return await new ActionInteraction(this, new LongPressAction()).execute();
+    return await new ActionInteraction(this._invocationManager, this, new LongPressAction()).execute();
   }
   async multiTap(times) {
-    return await new ActionInteraction(this, new MultiClickAction(times)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new MultiClickAction(times)).execute();
   }
   async tapBackspaceKey() {
-    return await new ActionInteraction(this, new PressKeyAction(67)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new PressKeyAction(67)).execute();
   }
   async tapReturnKey() {
-    return await new ActionInteraction(this, new TypeTextAction('\n')).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TypeTextAction('\n')).execute();
   }
   async typeText(value) {
-    return await new ActionInteraction(this, new TypeTextAction(value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TypeTextAction(value)).execute();
   }
   async replaceText(value) {
-    return await new ActionInteraction(this, new ReplaceTextAction(value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ReplaceTextAction(value)).execute();
   }
   async clearText() {
-    return await new ActionInteraction(this, new ClearTextAction()).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ClearTextAction()).execute();
   }
   async scroll(amount, direction = 'down') {
     // override the user's element selection with an extended matcher that looks for UIScrollView children
     // this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
-    return await new ActionInteraction(this, new ScrollAmountAction(direction, amount)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ScrollAmountAction(direction, amount)).execute();
   }
   async scrollTo(edge) {
     // override the user's element selection with an extended matcher that looks for UIScrollView children
     this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
-    return await new ActionInteraction(this, new ScrollEdgeAction(edge)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ScrollEdgeAction(edge)).execute();
   }
   async swipe(direction, speed = 'fast', percentage = 0) {
     // override the user's element selection with an extended matcher that avoids RN issues with RCTScrollView
     this._selectElementWithMatcher(this._originalMatcher._avoidProblematicReactNativeElements());
-    return await new ActionInteraction(this, new SwipeAction(direction, speed, percentage)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new SwipeAction(direction, speed, percentage)).execute();
   }
 }
 
-class Expect {}
+class Expect {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+  }
+}
 
 class ExpectElement extends Expect {
-  constructor(element) {
-    super();
+  constructor(invocationManager, element) {
+    super(invocationManager);
     this._element = element;
   }
   async toBeVisible() {
-    return await new MatcherAssertionInteraction(this._element, new VisibleMatcher()).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new VisibleMatcher()).execute();
   }
   async toBeNotVisible() {
-    return await invocationManager.execute(DetoxAssertionApi.assertNotVisible(call(this._element._call)));
+    return await this._invocationManager.execute(DetoxAssertionApi.assertNotVisible(call(this._element._call)));
   }
   async toExist() {
-    return await new MatcherAssertionInteraction(this._element, new ExistsMatcher()).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new ExistsMatcher()).execute();
   }
   async toNotExist() {
-    return await invocationManager.execute(DetoxAssertionApi.assertNotExists(call(this._element._call)));
+    return await this._invocationManager.execute(DetoxAssertionApi.assertNotExists(call(this._element._call)));
   }
   async toHaveText(value) {
-    return await new MatcherAssertionInteraction(this._element, new TextMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new TextMatcher(value)).execute();
   }
   async toHaveLabel(value) {
-    return await new MatcherAssertionInteraction(this._element, new LabelMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new LabelMatcher(value)).execute();
   }
   async toHaveId(value) {
-    return await new MatcherAssertionInteraction(this._element, new IdMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new IdMatcher(value)).execute();
   }
   async toHaveValue(value) {
-    return await new MatcherAssertionInteraction(this._element, new ValueMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new ValueMatcher(value)).execute();
   }
 }
 
-class WaitFor {}
+class WaitFor {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+  }
+}
 
 class WaitForElement extends WaitFor {
-  constructor(element) {
-    super();
+  constructor(invocationManager, element) {
+    super(invocationManager);
     //if ((!element instanceof Element)) throw new Error(`WaitForElement ctor argument must be a valid Element, got ${typeof element}`);
     this._element = element;
   }
   toBeVisible() {
-    return new WaitForInteraction(this._element, new VisibleMatcher());
+    return new WaitForInteraction(this._invocationManager, this._element, new VisibleMatcher());
   }
   toBeNotVisible() {
-    return new WaitForInteraction(this._element, new NotVisibleMatcher());
+    return new WaitForInteraction(this._invocationManager, this._element, new NotVisibleMatcher());
   }
   toExist() {
-    return new WaitForInteraction(this._element, new ExistsMatcher());
+    return new WaitForInteraction(this._invocationManager, this._element, new ExistsMatcher());
   }
   toNotExist() {
-    return new WaitForInteraction(this._element, new NotExistsMatcher());
+    return new WaitForInteraction(this._invocationManager, this._element, new NotExistsMatcher());
   }
   toHaveText(text) {
-    return new WaitForInteraction(this._element, new TextMatcher(text));
+    return new WaitForInteraction(this._invocationManager, this._element, new TextMatcher(text));
   }
   toHaveValue(value) {
-    return new WaitForInteraction(this._element, new ValueMatcher(value));
+    return new WaitForInteraction(this._invocationManager, this._element, new ValueMatcher(value));
   }
   toNotHaveValue(value) {
-    return new WaitForInteraction(this._element, new ValueMatcher(value).not());
+    return new WaitForInteraction(this._invocationManager, this._element, new ValueMatcher(value).not());
   }
 }
 
-function expect(element) {
-  if (element instanceof Element) return new ExpectElement(element);
-  throw new Error(`expect() argument is invalid, got ${typeof element}`);
-}
+class AndroidExpect {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
 
-function waitFor(element) {
-  if (element instanceof Element) return new WaitForElement(element);
-  throw new Error(`waitFor() argument is invalid, got ${typeof element}`);
-}
+    this.by = {
+      accessibilityLabel: value => new LabelMatcher(value),
+      label: value => new LabelMatcher(value),
+      id: value => new IdMatcher(value),
+      type: value => new TypeMatcher(value),
+      traits: value => new TraitsMatcher(value),
+      value: value => new ValueMatcher(value),
+      text: value => new TextMatcher(value)
+    };
+
+    this.element = this.element.bind(this);
+    this.expect = this.expect.bind(this);
+    this.waitFor = this.waitFor.bind(this);
+  }
+
+  element(matcher) {
+    return new Element(this._invocationManager, matcher);
+  }
+
+  expect(element) {
+    if (element instanceof Element) return new ExpectElement(this._invocationManager, element);
+    throw new Error(`expect() argument is invalid, got ${typeof element}`);
+  }
 
-function element(matcher) {
-  return new Element(matcher);
+  waitFor(element) {
+    if (element instanceof Element) return new WaitForElement(this._invocationManager, element);
+    throw new Error(`waitFor() argument is invalid, got ${typeof element}`);
+  }
 }
 
-const by = {
-  accessibilityLabel: value => new LabelMatcher(value),
-  label: value => new LabelMatcher(value),
-  id: value => new IdMatcher(value),
-  type: value => new TypeMatcher(value),
-  traits: value => new TraitsMatcher(value),
-  value: value => new ValueMatcher(value),
-  text: value => new TextMatcher(value)
-};
-
-const exportGlobals = () => {
-  global.element = element;
-  global.expect = expect;
-  global.waitFor = waitFor;
-  global.by = by;
-};
-
-module.exports = {
-  setInvocationManager,
-  exportGlobals,
-  expect,
-  waitFor,
-  element,
-  by
-};
+module.exports = AndroidExpect;
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/devices/android/ADB.js b/detox/src/devices/android/ADB.js
index 0e9393cf6..1f0b6cd17 100644
--- a/detox/src/devices/android/ADB.js
+++ b/detox/src/devices/android/ADB.js
@@ -13,7 +13,7 @@ class ADB {
   }
 
   async devices() {
-    const output = (await this.adbCmd('', 'devices')).stdout;
+    const output = (await this.adbCmd('', 'devices', { verbosity: 'high' })).stdout;
     return this.parseAdbDevicesConsoleOutput(output);
   }
 
diff --git a/detox/src/devices/drivers/AndroidDriver.js b/detox/src/devices/drivers/AndroidDriver.js
index c131c1e0a..6872430c2 100644
--- a/detox/src/devices/drivers/AndroidDriver.js
+++ b/detox/src/devices/drivers/AndroidDriver.js
@@ -20,6 +20,7 @@ const DetoxRuntimeError = require('../../errors/DetoxRuntimeError');
 const sleep = require('../../utils/sleep');
 const retry = require('../../utils/retry');
 const { interruptProcess, spawnAndLog } = require('../../utils/exec');
+const AndroidExpect = require('../../android/expect');
 
 const EspressoDetox = 'com.wix.detox.espresso.EspressoDetox';
 
@@ -27,9 +28,8 @@ class AndroidDriver extends DeviceDriverBase {
   constructor(config) {
     super(config);
 
-    this.expect = require('../../android/expect');
     this.invocationManager = new InvocationManager(this.client);
-    this.expect.setInvocationManager(this.invocationManager);
+    this.matchers = new AndroidExpect(this.invocationManager);
 
     this.adb = new ADB();
     this.aapt = new AAPT();
@@ -48,10 +48,6 @@ class AndroidDriver extends DeviceDriverBase {
     };
   }
 
-  exportGlobals() {
-    this.expect.exportGlobals();
-  }
-
   async getBundleIdFromBinary(apkPath) {
     return await this.aapt.getPackageName(apkPath);
   }
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index de9fd2299..a961af3a5 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -8,6 +8,7 @@ const log = require('../../utils/logger').child({ __filename });
 class DeviceDriverBase {
   constructor({ client }) {
     this.client = client;
+    this.matchers = null;
     this.emitter = new AsyncEmitter({
       events: [
         'bootDevice',
diff --git a/detox/src/devices/drivers/IosDriver.js b/detox/src/devices/drivers/IosDriver.js
index 12d2dee5f..5dd9fae69 100644
--- a/detox/src/devices/drivers/IosDriver.js
+++ b/detox/src/devices/drivers/IosDriver.js
@@ -13,15 +13,14 @@ const SimulatorLogPlugin = require('../../artifacts/log/ios/SimulatorLogPlugin')
 const SimulatorScreenshotPlugin = require('../../artifacts/screenshot/SimulatorScreenshotPlugin');
 const SimulatorRecordVideoPlugin = require('../../artifacts/video/SimulatorRecordVideoPlugin');
 const SimulatorInstrumentsPlugin = require('../../artifacts/instruments/SimulatorInstrumentsPlugin');
+const IosExpect = require('../../ios/expect');
 
 class IosDriver extends DeviceDriverBase {
   constructor(config) {
     super(config);
 
     this.applesimutils = new AppleSimUtils();
-
-    this.expect = require('../../ios/expect');
-    this.expect.setInvocationManager(new InvocationManager(this.client));
+    this.matchers = new IosExpect(new InvocationManager(this.client));
   }
 
   declareArtifactPlugins() {
@@ -36,10 +35,6 @@ class IosDriver extends DeviceDriverBase {
     };
   }
 
-  exportGlobals() {
-    this.expect.exportGlobals();
-  }
-
   createPayloadFile(notification) {
     const notificationFilePath = path.join(this.createRandomDirectory(), `payload.json`);
     fs.writeFileSync(notificationFilePath, JSON.stringify(notification, null, 2));
diff --git a/detox/src/devices/ios/AppleSimUtils.js b/detox/src/devices/ios/AppleSimUtils.js
index 86fb21446..bbd757f7d 100644
--- a/detox/src/devices/ios/AppleSimUtils.js
+++ b/detox/src/devices/ios/AppleSimUtils.js
@@ -14,7 +14,7 @@ class AppleSimUtils {
       permissions.push(permission + '=' + shouldAllow);
     });
     await this._execAppleSimUtils({
-      args: `--simulator ${udid} --bundle ${bundleId} --restartSB --setPermissions ${_.join(permissions, ',')}`
+      args: `--byId ${udid} --bundle ${bundleId} --restartSB --setPermissions ${_.join(permissions, ',')}`
     }, statusLogs, 1);
   }
 
@@ -219,7 +219,8 @@ class AppleSimUtils {
   }
 
   async _execSimctl({ cmd, statusLogs = {}, retries = 1, silent = false }) {
-    return await exec.execWithRetriesAndLogs(`/usr/bin/xcrun simctl ${cmd}`, { silent }, statusLogs, retries);
+    const verbosity = silent ? 'low' : 'normal';
+    return await exec.execWithRetriesAndLogs(`/usr/bin/xcrun simctl ${cmd}`, { verbosity }, statusLogs, retries);
   }
 
   _parseResponseFromAppleSimUtils(response) {
diff --git a/detox/src/exportWrapper.js b/detox/src/exportWrapper.js
deleted file mode 100644
index 8d7fda36e..000000000
--- a/detox/src/exportWrapper.js
+++ /dev/null
@@ -1,52 +0,0 @@
-const platform = require('./platform');
-const iosExports = require('./ios/expect');
-const androidExports = require('./android/expect');
-
-const exportMap = {
-  expect: {
-    ios: iosExports.expect,
-    android: androidExports.expect,
-  },
-  element: {
-    ios: iosExports.element,
-    android: androidExports.element,
-  },
-  waitFor: {
-    ios: iosExports.waitFor,
-    android: androidExports.waitFor,
-  },
-  by: {
-    ios: iosExports.by,
-    android: androidExports.by,
-  }
-};
-
-function applyToPlatformSpecific(name, args) {
-  return exportMap[name][platform.get('name')].apply(null, args);
-}
-
-module.exports = {
-  element() {
-    return applyToPlatformSpecific('element', arguments);
-  },
-
-  expect() {
-    return applyToPlatformSpecific('expect', arguments);
-  },
-
-  waitFor() {
-    return applyToPlatformSpecific('waitFor', arguments);
-  },
-
-  by: new Proxy({}, {
-    get(target, name) {
-      return exportMap.by[platform.get('name')][name];
-    }
-  }),
-
-  device: new Proxy({}, {
-    get(target, name) {
-      return platform.get('device')[name];
-    }
-  }),
-};
diff --git a/detox/src/index.js b/detox/src/index.js
index e98ebbc02..e17b3c485 100644
--- a/detox/src/index.js
+++ b/detox/src/index.js
@@ -1,92 +1,3 @@
-const _ = require('lodash');
-const Detox = require('./Detox');
-const DetoxConstants = require('./DetoxConstants');
-const platform = require('./platform');
-const exportWrapper = require('./exportWrapper');
-const argparse = require('./utils/argparse');
-const log = require('./utils/logger').child({ __filename });
-const configuration = require('./configuration');
+const DetoxExportWrapper = require('./DetoxExportWrapper');
 
-let detox;
-
-function getDeviceConfig(configurations) {
-  const configurationName = argparse.getArgValue('configuration');
-  const deviceOverride = argparse.getArgValue('device-name');
-
-  const deviceConfig = (!configurationName && _.size(configurations) === 1)
-    ? _.values(configurations)[0]
-    : configurations[configurationName];
-
-  if (!deviceConfig) {
-    throw new Error(`Cannot determine which configuration to use. use --configuration to choose one of the following:
-                      ${Object.keys(configurations)}`);
-  }
-
-  if (deviceOverride) {
-    deviceConfig.name = deviceOverride;
-  }
-
-  if (!deviceConfig.type) {
-    configuration.throwOnEmptyType();
-  }
-
-  if (!deviceConfig.name) {
-    configuration.throwOnEmptyName();
-  }
-
-  return deviceConfig;
-}
-
-async function initializeDetox(config, params) {
-  if (!config) {
-    throw new Error(`No configuration was passed to detox, make sure you pass a config when calling 'detox.init(config)'`);
-  }
-
-  if (!(config.configurations && _.size(config.configurations) >= 1)) {
-    throw new Error(`No configured devices`);
-  }
-
-  const {configurations, session} = config;
-  const deviceConfig = getDeviceConfig(configurations);
-
-  detox = new Detox({deviceConfig, session});
-  await detox.init(params);
-  platform.set(deviceConfig.type, detox.device);
-}
-
-async function init(config, params) {
-  try {
-    await initializeDetox(config, params);
-  } catch (err) {
-    log.error({ event: 'DETOX_INIT_ERROR' }, '\n', err);
-    await cleanup();
-    throw err;
-  }
-}
-
-async function beforeEach(testSummary) {
-  if (detox) {
-    await detox.beforeEach(testSummary);
-  }
-}
-
-async function afterEach(testSummary) {
-  if (detox) {
-    await detox.afterEach(testSummary);
-  }
-}
-
-async function cleanup() {
-    if (detox) {
-        await detox.cleanup();
-        detox = null;
-    }
-}
-
-module.exports = Object.assign({
-  init,
-  cleanup,
-  beforeEach,
-  afterEach,
-  DetoxConstants
-}, exportWrapper);
+module.exports = new DetoxExportWrapper();
diff --git a/detox/src/ios/expect.js b/detox/src/ios/expect.js
index 8cf39c000..39487afe1 100644
--- a/detox/src/ios/expect.js
+++ b/detox/src/ios/expect.js
@@ -17,12 +17,6 @@ const GreyCondition = require('./earlgreyapi/GREYCondition');
 const GreyConditionDetox = require('./earlgreyapi/GREYConditionDetox');
 const GreyActionsDetox = require('./earlgreyapi/GREYActions+Detox');
 
-let invocationManager;
-
-function setInvocationManager(im) {
-  invocationManager = im;
-}
-
 function callThunk(element) {
   return typeof element._call === 'function' ? element._call() : element._call;
 }
@@ -89,9 +83,9 @@ class PinchAction extends Action {
     if (typeof direction !== 'string') throw new Error(`PinchAction ctor 1st argument must be a string, got ${typeof direction}`);
     if (typeof speed !== 'string') throw new Error(`PinchAction ctor 2nd argument must be a string, got ${typeof speed}`);
     if (typeof angle !== 'number') throw new Error(`PinchAction ctor 3nd argument must be a number, got ${typeof angle}`);
-    if (speed == 'fast') {
+    if (speed === 'fast') {
       this._call = invoke.callDirectly(GreyActions.actionForPinchFastInDirectionWithAngle(direction, angle));
-    } else if (speed == 'slow') {
+    } else if (speed === 'slow') {
       this._call = invoke.callDirectly(GreyActions.actionForPinchSlowInDirectionWithAngle(direction, angle));
     } else {
       throw new Error(`PinchAction speed must be a 'fast'/'slow', got ${speed}`);
@@ -159,11 +153,11 @@ class SwipeAction extends Action {
           break;
       }
 
-      if (speed == 'fast') {
+      if (speed === 'fast') {
         this._call = invoke.callDirectly(
           GreyActions.actionForSwipeFastInDirectionXOriginStartPercentageYOriginStartPercentage(direction, x, y)
         );
-      } else if (speed == 'slow') {
+      } else if (speed === 'slow') {
         this._call = invoke.callDirectly(
           GreyActions.actionForSwipeSlowInDirectionXOriginStartPercentageYOriginStartPercentage(direction, x, y)
         );
@@ -171,9 +165,9 @@ class SwipeAction extends Action {
         throw new Error(`SwipeAction speed must be a 'fast'/'slow', got ${speed}`);
       }
     } else {
-      if (speed == 'fast') {
+      if (speed === 'fast') {
         this._call = invoke.callDirectly(GreyActions.actionForSwipeFastInDirection(direction));
-      } else if (speed == 'slow') {
+      } else if (speed === 'slow') {
         this._call = invoke.callDirectly(GreyActions.actionForSwipeSlowInDirection(direction));
       } else {
         throw new Error(`SwipeAction speed must be a 'fast'/'slow', got ${speed}`);
@@ -197,30 +191,34 @@ class SetDatePickerDate extends Action {
 }
 
 class Interaction {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+  }
+
   async execute() {
     //if (!this._call) throw new Error(`Interaction.execute cannot find a valid _call, got ${typeof this._call}`);
-    await invocationManager.execute(this._call);
+    await this._invocationManager.execute(this._call);
   }
 }
 
 class ActionInteraction extends Interaction {
-  constructor(element, action) {
-    super();
+  constructor(invocationManager, element, action) {
+    super(invocationManager);
 
     this._call = GreyInteraction.performAction(invoke.callDirectly(callThunk(element)), callThunk(action));
   }
 }
 
 class MatcherAssertionInteraction extends Interaction {
-  constructor(element, matcher) {
-    super();
+  constructor(invocationManager, element, matcher) {
+    super(invocationManager);
     this._call = GreyInteraction.assertWithMatcher(invoke.callDirectly(callThunk(element)), callThunk(matcher));
   }
 }
 
 class WaitForInteraction extends Interaction {
-  constructor(element, matcher) {
-    super();
+  constructor(invocationManager, element, matcher) {
+    super(invocationManager);
     //if (!(element instanceof Element)) throw new Error(`WaitForInteraction ctor 1st argument must be a valid Element, got ${typeof element}`);
     //if (!(matcher instanceof Matcher)) throw new Error(`WaitForInteraction ctor 2nd argument must be a valid Matcher, got ${typeof matcher}`);
     this._element = element;
@@ -243,17 +241,17 @@ class WaitForInteraction extends Interaction {
       _conditionCall = GreyConditionDetox.detoxConditionForNotElementMatched(callThunk(this._element));
     }
 
-    this._call = GreyCondition.waitWithTimeout(invoke.callDirectly(_conditionCall), timeout / 1000)
+    this._call = GreyCondition.waitWithTimeout(invoke.callDirectly(_conditionCall), timeout / 1000);
     await this.execute();
   }
   whileElement(searchMatcher) {
-    return new WaitForActionInteraction(this._element, this._originalMatcher, searchMatcher);
+    return new WaitForActionInteraction(this._invocationManager, this._element, this._originalMatcher, searchMatcher);
   }
 }
 
 class WaitForActionInteraction extends Interaction {
-  constructor(element, matcher, searchMatcher) {
-    super();
+  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}`);
     //if (!(matcher instanceof Matcher)) throw new Error(`WaitForActionInteraction ctor 2nd argument must be a valid Matcher, got ${typeof matcher}`);
     if (!(searchMatcher instanceof Matcher)) throw new Error(`WaitForActionInteraction ctor 3rd argument must be a valid Matcher, got ${typeof searchMatcher}`);
@@ -276,7 +274,8 @@ class WaitForActionInteraction extends Interaction {
 }
 
 class Element {
-  constructor(matcher) {
+  constructor(invocationManager, matcher) {
+    this._invocationManager = invocationManager;
     this._originalMatcher = matcher;
     this._selectElementWithMatcher(this._originalMatcher);
   }
@@ -291,158 +290,162 @@ class Element {
     return this;
   }
   async tap() {
-    return await new ActionInteraction(this, new TapAction()).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TapAction()).execute();
   }
   async tapAtPoint(value) {
-    return await new ActionInteraction(this, new TapAtPointAction(value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TapAtPointAction(value)).execute();
   }
   async longPress(duration) {
-    return await new ActionInteraction(this, new LongPressAction(duration)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new LongPressAction(duration)).execute();
   }
   async multiTap(value) {
-    return await new ActionInteraction(this, new MultiTapAction(value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new MultiTapAction(value)).execute();
   }
   async tapBackspaceKey() {
-    return await new ActionInteraction(this, new TypeTextAction('\b')).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TypeTextAction('\b')).execute();
   }
   async tapReturnKey() {
-    return await new ActionInteraction(this, new TypeTextAction('\n')).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TypeTextAction('\n')).execute();
   }
   async typeText(value) {
-    return await new ActionInteraction(this, new TypeTextAction(value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new TypeTextAction(value)).execute();
   }
   async replaceText(value) {
-    return await new ActionInteraction(this, new ReplaceTextAction(value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ReplaceTextAction(value)).execute();
   }
   async clearText() {
-    return await new ActionInteraction(this, new ClearTextAction()).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ClearTextAction()).execute();
   }
   async pinchWithAngle(direction, speed = 'slow', angle = 0) {
-    return await new ActionInteraction(this, new PinchAction(direction, speed, angle)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new PinchAction(direction, speed, angle)).execute();
   }
   async scroll(amount, direction = 'down', startScrollX, startScrollY) {
     // override the user's element selection with an extended matcher that looks for UIScrollView children
     this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
-    return await new ActionInteraction(this, new ScrollAmountAction(direction, amount, startScrollX, startScrollY)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ScrollAmountAction(direction, amount, startScrollX, startScrollY)).execute();
   }
   async scrollTo(edge) {
     // override the user's element selection with an extended matcher that looks for UIScrollView children
     this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
-    return await new ActionInteraction(this, new ScrollEdgeAction(edge)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ScrollEdgeAction(edge)).execute();
   }
   async swipe(direction, speed = 'fast', percentage = 0) {
     // override the user's element selection with an extended matcher that avoids RN issues with RCTScrollView
     this._selectElementWithMatcher(this._originalMatcher._avoidProblematicReactNativeElements());
-    return await new ActionInteraction(this, new SwipeAction(direction, speed, percentage)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new SwipeAction(direction, speed, percentage)).execute();
   }
   async setColumnToValue(column,value) {
-    return await new ActionInteraction(this, new ScrollColumnToValue(column, value)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new ScrollColumnToValue(column, value)).execute();
   }
   async setDatePickerDate(dateString, dateFormat) {
-    return await new ActionInteraction(this, new SetDatePickerDate(dateString, dateFormat)).execute();
+    return await new ActionInteraction(this._invocationManager, this, new SetDatePickerDate(dateString, dateFormat)).execute();
   }
 }
 
-class Expect { }
+class Expect {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+  }
+}
 
 class ExpectElement extends Expect {
-  constructor(element) {
-    super();
+  constructor(invocationManager, element) {
+    super(invocationManager);
     this._element = element;
   }
   async toBeVisible() {
-    return await new MatcherAssertionInteraction(this._element, new VisibleMatcher()).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new VisibleMatcher()).execute();
   }
   async toBeNotVisible() {
-    return await new MatcherAssertionInteraction(this._element, new NotVisibleMatcher()).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new NotVisibleMatcher()).execute();
   }
   async toExist() {
-    return await new MatcherAssertionInteraction(this._element, new ExistsMatcher()).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new ExistsMatcher()).execute();
   }
   async toNotExist() {
-    return await new MatcherAssertionInteraction(this._element, new NotExistsMatcher()).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new NotExistsMatcher()).execute();
   }
   async toHaveText(value) {
-    return await new MatcherAssertionInteraction(this._element, new TextMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new TextMatcher(value)).execute();
   }
   async toHaveLabel(value) {
-    return await new MatcherAssertionInteraction(this._element, new LabelMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new LabelMatcher(value)).execute();
   }
   async toHaveId(value) {
-    return await new MatcherAssertionInteraction(this._element, new IdMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new IdMatcher(value)).execute();
   }
   async toHaveValue(value) {
-    return await new MatcherAssertionInteraction(this._element, new ValueMatcher(value)).execute();
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, new ValueMatcher(value)).execute();
   }
 }
 
-class WaitFor { }
+class WaitFor {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+  }
+}
 
 class WaitForElement extends WaitFor {
-  constructor(element) {
-    super();
+  constructor(invocationManager, element) {
+    super(invocationManager);
     //if ((!element instanceof Element)) throw new Error(`WaitForElement ctor argument must be a valid Element, got ${typeof element}`);
     this._element = element;
   }
   toBeVisible() {
-    return new WaitForInteraction(this._element, new VisibleMatcher());
+    return new WaitForInteraction(this._invocationManager, this._element, new VisibleMatcher());
   }
   toBeNotVisible() {
-    return new WaitForInteraction(this._element, new VisibleMatcher())._not();
+    return new WaitForInteraction(this._invocationManager, this._element, new VisibleMatcher())._not();
   }
   toExist() {
-    return new WaitForInteraction(this._element, new ExistsMatcher());
+    return new WaitForInteraction(this._invocationManager, this._element, new ExistsMatcher());
   }
   toNotExist() {
-    return new WaitForInteraction(this._element, new ExistsMatcher())._not();
+    return new WaitForInteraction(this._invocationManager, this._element, new ExistsMatcher())._not();
   }
   toHaveText(text) {
-    return new WaitForInteraction(this._element, new TextMatcher(text));
+    return new WaitForInteraction(this._invocationManager, this._element, new TextMatcher(text));
   }
   toHaveValue(value) {
-    return new WaitForInteraction(this._element, new ValueMatcher(value));
+    return new WaitForInteraction(this._invocationManager, this._element, new ValueMatcher(value));
   }
   toNotHaveValue(value) {
-    return new WaitForInteraction(this._element, new ValueMatcher(value))._not();
+    return new WaitForInteraction(this._invocationManager, this._element, new ValueMatcher(value))._not();
   }
 }
 
-function expect(element) {
-  if (element instanceof Element) return new ExpectElement(element);
-  throw new Error(`expect() argument is invalid, got ${typeof element}`);
-}
+class IosExpect {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
 
-function waitFor(element) {
-  if (element instanceof Element) return new WaitForElement(element);
-  throw new Error(`waitFor() argument is invalid, got ${typeof element}`);
-}
+    this.by = {
+      accessibilityLabel: (value) => new LabelMatcher(value),
+      label: (value) => new LabelMatcher(value),
+      id: (value) => new IdMatcher(value),
+      type: (value) => new TypeMatcher(value),
+      traits: (value) => new TraitsMatcher(value),
+      value: (value) => new ValueMatcher(value),
+      text: (value) => new TextMatcher(value)
+    };
+
+    this.element = this.element.bind(this);
+    this.expect = this.expect.bind(this);
+    this.waitFor = this.waitFor.bind(this);
+  }
+
+  expect(element) {
+    if (element instanceof Element) return new ExpectElement(this._invocationManager, element);
+    throw new Error(`expect() argument is invalid, got ${typeof element}`);
+  }
 
-function element(matcher) {
-  return new Element(matcher);
+  element(matcher) {
+    return new Element(this._invocationManager, matcher);
+  }
+
+  waitFor(element) {
+    if (element instanceof Element) return new WaitForElement(this._invocationManager, element);
+    throw new Error(`waitFor() argument is invalid, got ${typeof element}`);
+  }
 }
 
-const by = {
-  accessibilityLabel: (value) => new LabelMatcher(value),
-  label: (value) => new LabelMatcher(value),
-  id: (value) => new IdMatcher(value),
-  type: (value) => new TypeMatcher(value),
-  traits: (value) => new TraitsMatcher(value),
-  value: (value) => new ValueMatcher(value),
-  text: (value) => new TextMatcher(value)
-};
-
-const exportGlobals = () => {
-  global.element = element;
-  global.expect = expect;
-  global.waitFor = waitFor;
-  global.by = by;
-};
-
-module.exports = {
-  setInvocationManager,
-  exportGlobals,
-  expect,
-  waitFor,
-  element,
-  by
-};
+module.exports = IosExpect;
diff --git a/detox/src/platform.js b/detox/src/platform.js
deleted file mode 100644
index 151215ec4..000000000
--- a/detox/src/platform.js
+++ /dev/null
@@ -1,19 +0,0 @@
-class Platform {
-  constructor() {
-    this.name = null;
-    this.device = null;
-  }
-
-  get(entry) {
-    return this[entry];
-  }
-
-  set(type, device) {
-    this.name = type.split('.')[0];
-    this.device = device;
-  }
-}
-
-const platform = new Platform();
-
-module.exports = platform;
\ No newline at end of file
diff --git a/detox/src/server/DetoxServer.js b/detox/src/server/DetoxServer.js
index 7f748502c..ea7d23366 100644
--- a/detox/src/server/DetoxServer.js
+++ b/detox/src/server/DetoxServer.js
@@ -45,10 +45,18 @@ class DetoxServer {
         }
       });
 
+      ws.on('error', (e) => {
+        this.log.warn({ event: 'WEBSOCKET_ERROR', role, sessionId }, `${e && e.message} (role=${role}, session=${sessionId})`);
+      });
+
       ws.on('close', () => {
         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 +78,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/detox/src/utils/exec.js b/detox/src/utils/exec.js
index 1d23333e2..47581a3db 100644
--- a/detox/src/utils/exec.js
+++ b/detox/src/utils/exec.js
@@ -12,6 +12,7 @@ async function execWithRetriesAndLogs(bin, options, statusLogs, retries = 10, in
   const cmd = _composeCommand(bin, options);
   const execTimeout = _.get(options, 'timeout', 0);
   const log = execLogger.child({ fn: 'execWithRetriesAndLogs', cmd, trackingId });
+  const verbosity = _.get(options, 'verbosity', 'normal');
   log.debug({ event: 'EXEC_CMD' }, `${cmd}`);
 
   let result;
@@ -28,8 +29,7 @@ async function execWithRetriesAndLogs(bin, options, statusLogs, retries = 10, in
       ? `timeout = ${execTimeout}ms`
       : `code = ${err.code}`;
 
-    const silent = _.get(options, 'silent', false);
-    const level = silent ? 'debug' : 'error';
+    const level = (verbosity === 'low' ? 'debug' : 'error');
 
     log[level]({ event: 'EXEC_FAIL' }, `"${cmd}" failed with ${_failReason}, stdout and stderr:\n`);
     log[level]({ event: 'EXEC_FAIL', stdout: true }, err.stdout);
@@ -43,7 +43,7 @@ async function execWithRetriesAndLogs(bin, options, statusLogs, retries = 10, in
     throw new DetoxRuntimeError(`command ${cmd} returned undefined`);
   }
 
-  _logExecOutput(log, result);
+  _logExecOutput(log, result, verbosity === 'high' ? 'debug' : 'trace');
 
   if (statusLogs && statusLogs.successful) {
     log.debug({ event: 'EXEC_SUCCESS' }, statusLogs.successful);
@@ -66,7 +66,7 @@ async function execWithRetriesAndLogs(bin, options, statusLogs, retries = 10, in
 }
 
 /* istanbul ignore next */
-function _logExecOutput(log, process) {
+function _logExecOutput(log, process, level) {
   let stdout = process.stdout || '';
   let stderr = process.stderr || '';
 
@@ -76,15 +76,15 @@ function _logExecOutput(log, process) {
   }
 
   if (stdout) {
-    log.trace({ event: 'EXEC_SUCCESS', stdout: true }, stdout);
+    log[level]({ event: 'EXEC_SUCCESS', stdout: true }, stdout);
   }
 
   if (stderr) {
-    log.trace({ event: 'EXEC_SUCCESS', stderr: true }, stderr);
+    log[level]({ event: 'EXEC_SUCCESS', stderr: true }, stderr);
   }
 
   if (!stdout && !stderr) {
-    log.trace({ event: 'EXEC_SUCCESS' }, '');
+    log[level]({ event: 'EXEC_SUCCESS' }, '');
   }
 }
 
diff --git a/docs/Guide.Expo.md b/docs/Guide.Expo.md
index ea1106d24..1ab96d335 100644
--- a/docs/Guide.Expo.md
+++ b/docs/Guide.Expo.md
@@ -39,5 +39,8 @@ beforeEach(async () => {
 
 - Usage with Android is currently TBD.
 
+## Known issues
+[Timeout on opening the app](https://github.com/wix/Detox/issues/1422) - Expo not working with detox > 12.3.0
+
 ### Example App
-[expo/with-detox-tests](https://github.com/expo/with-detox-tests)
+[yaron1m/expo-detox-typescript-example](https://github.com/yaron1m/expo-detox-typescript-example)
diff --git a/docs/Guide.Mocking.md b/docs/Guide.Mocking.md
index 334f94816..b89e6e3a7 100644
--- a/docs/Guide.Mocking.md
+++ b/docs/Guide.Mocking.md
@@ -26,7 +26,7 @@ This replacement mechanism provides a lot of flexibility to change implementatio
 
 #### Configuration
 0. For RN < 0.55, setup `react-native-repackager` in your library.
-1. Configure Metro by creating `rn-cli.config.js` to root dir and setting `resolver.sourceExts` to prioritize any given source extension over the default one:
+1. For case 0.55 <= RN < 0.59 create a file called `rn-cli.config.js` in the root folder. If you use RN > 0.59 (which in turn uses Metro with breaking changes introduced in 0.43 - https://github.com/facebook/metro/releases/tag/v0.43.0) file should have name `metro.config.js` or `metro.config.json` (or define metro field in `package.json`) to root dir. Then set up `resolver.sourceExts` to prioritize any given source extension over the default one:
 
     ```js
     const defaultSourceExts = require('metro-config/src/defaults/defaults').sourceExts
diff --git a/examples/demo-native-android/package.json b/examples/demo-native-android/package.json
index d82955ef8..3139be9bf 100644
--- a/examples/demo-native-android/package.json
+++ b/examples/demo-native-android/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-demo-native-android",
-  "version": "12.11.0",
+  "version": "12.11.2",
   "private": true,
   "scripts": {
     "packager": "react-native start",
@@ -8,7 +8,7 @@
     "e2e": "mocha e2e --opts ./e2e/mocha.opts"
   },
   "devDependencies": {
-    "detox": "^12.11.0",
+    "detox": "^12.11.2",
     "mocha": "^6.1.3"
   },
   "detox": {}
diff --git a/examples/demo-native-ios/package.json b/examples/demo-native-ios/package.json
index 3f9af8bee..dc67b3317 100644
--- a/examples/demo-native-ios/package.json
+++ b/examples/demo-native-ios/package.json
@@ -1,9 +1,9 @@
 {
   "name": "detox-demo-native-ios",
-  "version": "12.11.0",
+  "version": "12.11.2",
   "private": true,
   "devDependencies": {
-    "detox": "^12.11.0",
+    "detox": "^12.11.2",
     "mocha": "^6.1.3"
   },
   "detox": {
diff --git a/examples/demo-react-native-detox-instruments/package.json b/examples/demo-react-native-detox-instruments/package.json
index 6caa549c9..814cd9b05 100644
--- a/examples/demo-react-native-detox-instruments/package.json
+++ b/examples/demo-react-native-detox-instruments/package.json
@@ -1,10 +1,10 @@
 {
   "name": "demo-react-native-detox-instruments",
-  "version": "12.11.0",
+  "version": "12.11.2",
   "private": true,
   "scripts": {},
   "devDependencies": {
-    "detox": "^12.11.0",
+    "detox": "^12.11.2",
     "mocha": "6.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native-jest/package.json b/examples/demo-react-native-jest/package.json
index 4d2ccaf5b..66b68f1fa 100644
--- a/examples/demo-react-native-jest/package.json
+++ b/examples/demo-react-native-jest/package.json
@@ -1,12 +1,12 @@
 {
   "name": "demo-react-native-jest",
-  "version": "12.11.0",
+  "version": "12.11.2",
   "private": true,
   "scripts": {
     "test:android-release": "detox test --configuration android.emu.release -l verbose"
   },
   "devDependencies": {
-    "detox": "^12.11.0",
+    "detox": "^12.11.2",
     "jest": "24.x.x"
   },
   "detox": {
diff --git a/examples/demo-react-native/package.json b/examples/demo-react-native/package.json
index a84bbcc53..7f2e71569 100644
--- a/examples/demo-react-native/package.json
+++ b/examples/demo-react-native/package.json
@@ -1,6 +1,6 @@
 {
   "name": "example",
-  "version": "12.11.0",
+  "version": "12.11.2",
   "private": true,
   "scripts": {
     "start": "react-native start",
@@ -20,7 +20,7 @@
     "react-native": "0.59.8"
   },
   "devDependencies": {
-    "detox": "^12.11.0",
+    "detox": "^12.11.2",
     "mocha": "^6.1.3"
   },
   "detox": {
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/check-for-git-changes.sh b/generation/check-for-git-changes.sh
index 05cc3e534..8161d0bf1 100755
--- a/generation/check-for-git-changes.sh
+++ b/generation/check-for-git-changes.sh
@@ -13,10 +13,10 @@ if [ "isGitClean" = true ] ; then
     git stash
 fi
 
-
 npm run build
 
-if [[ $(git diff -- . ':(exclude)/*package.json') ]]; then
+gitDiff=`git --no-pager diff -- .. ":(exclude)../**/package.json"`
+if [[ -n "${gitDiff}" ]]; then
     printf "\n\n"
     echo "There seem to be changes after running the code generation."
     echo "This might be because you updated a native dependency and forgot to run the code generation."
@@ -25,7 +25,8 @@ if [[ $(git diff -- . ':(exclude)/*package.json') ]]; then
     echo "Another source of this error might be that you have changed generated code and checked it in."
     echo "You should not be doing this, please change the source of the generated code and not the code itself."
     printf "\n"
-    echo "$(git diff)"
+    echo "Diff summary:"
+    echo "${gitDiff}"
 
     if [ "isGitClean" = true ] ; then
       git stash pop
@@ -35,4 +36,4 @@ else
     if [ "isGitClean" = true ] ; then
       git stash pop
     fi
-fi
\ No newline at end of file
+fi
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',
diff --git a/generation/package.json b/generation/package.json
index 6ddb3e81b..51fc7e711 100644
--- a/generation/package.json
+++ b/generation/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "generation",
-	"version": "12.4.0",
+	"version": "12.11.2",
 	"description": "Generate wrapper code for native dependencies",
 	"main": "index.js",
 	"private": true,
diff --git a/lerna.json b/lerna.json
index 9e5816cc0..475cb2a55 100644
--- a/lerna.json
+++ b/lerna.json
@@ -11,7 +11,7 @@
     "examples/demo-react-native-jest",
     "generation"
   ],
-  "version": "12.11.0",
+  "version": "12.11.2",
   "npmClient": "npm",
   "command": {
     "publish": {


Test patch:
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index a6c9a922b..e8310a965 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -161,89 +161,63 @@ describe('Detox', () => {
     Detox = require('./Detox');
     detox = new Detox({deviceConfig: validDeviceConfigWithSession});
     await detox.init();
-    detox.client.getPendingCrashAndReset.mockReturnValueOnce('crash');
+    detox._client.getPendingCrashAndReset.mockReturnValueOnce('crash'); // TODO: rewrite to avoid accessing private fields
     await detox.afterEach({ title: 'a', fullName: 'b', status: 'failed' });
     expect(device.launchApp).toHaveBeenCalledTimes(1);
   });
 
   describe('.artifactsManager', () => {
-    it(`Calling detox.init() should trigger artifactsManager.beforeAll()`, async () => {
-      Detox = require('./Detox');
+    let artifactsManager;
 
+    beforeEach(async () => {
+      jest.mock('./artifacts/ArtifactsManager');
+      Detox = require('./Detox');
       detox = new Detox({deviceConfig: validDeviceConfig});
-      detox.artifactsManager.onBeforeAll = jest.fn();
       await detox.init();
-      expect(detox.artifactsManager.onBeforeAll).toHaveBeenCalledTimes(1);
+      artifactsManager = detox._artifactsManager; // TODO: rewrite to avoid accessing private fields
     });
 
-    it(`Calling .beforeEach() will trigger artifacts manager .onBeforeEach`, async () => {
-      Detox = require('./Detox');
-
-      detox = new Detox({deviceConfig: validDeviceConfig});
-      detox.artifactsManager.onBeforeEach = jest.fn();
+    it(`Calling detox.init() should trigger artifactsManager.beforeAll()`, async () => {
+      expect(artifactsManager.onBeforeAll).toHaveBeenCalledTimes(1);
+    });
 
-      await detox.init();
+    it(`Calling detox.beforeEach() will trigger artifacts manager .onBeforeEach`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'running' };
       await detox.beforeEach(testSummary);
 
-      expect(detox.artifactsManager.onBeforeEach).toHaveBeenCalledWith(testSummary);
+      expect(artifactsManager.onBeforeEach).toHaveBeenCalledWith(testSummary);
     });
 
-    it(`Calling .beforeEach() and .afterEach() with a deprecated signature will throw an exception`, async () => {
-      Detox = require('./Detox');
-
-      detox = new Detox({deviceConfig: validDeviceConfig});
-      detox.artifactsManager.onBeforeEach = jest.fn();
-      detox.artifactsManager.onAfterEach = jest.fn();
-
-      await detox.init();
+    it(`Calling detox.beforeEach() and detox.afterEach() with a deprecated signature will throw an exception`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'running' };
 
       await expect(detox.beforeEach(testSummary.title, testSummary.fullName, testSummary.status)).rejects.toThrowErrorMatchingSnapshot();
-      expect(detox.artifactsManager.onBeforeEach).not.toHaveBeenCalled();
+      expect(artifactsManager.onBeforeEach).not.toHaveBeenCalled();
 
       await expect(detox.afterEach(testSummary.title, testSummary.fullName, testSummary.status)).rejects.toThrowErrorMatchingSnapshot();
-      expect(detox.artifactsManager.onAfterEach).not.toHaveBeenCalled();
+      expect(artifactsManager.onAfterEach).not.toHaveBeenCalled();
     });
 
-    it(`Calling .beforeEach() and .afterEach() with incorrect test status will throw an exception`, async () => {
-      Detox = require('./Detox');
-
-      detox = new Detox({deviceConfig: validDeviceConfig});
-      detox.artifactsManager.onBeforeEach = jest.fn();
-      detox.artifactsManager.onAfterEach = jest.fn();
-
-      await detox.init();
+    it(`Calling detox.beforeEach() and detox.afterEach() with incorrect test status will throw an exception`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'incorrect status' };
 
       await expect(detox.beforeEach(testSummary)).rejects.toThrowErrorMatchingSnapshot();
-      expect(detox.artifactsManager.onBeforeEach).not.toHaveBeenCalled();
+      expect(artifactsManager.onBeforeEach).not.toHaveBeenCalled();
 
-      await expect(detox.beforeEach(testSummary)).rejects.toThrowErrorMatchingSnapshot();
-      expect(detox.artifactsManager.onAfterEach).not.toHaveBeenCalled();
+      await expect(detox.afterEach(testSummary)).rejects.toThrowErrorMatchingSnapshot();
+      expect(artifactsManager.onAfterEach).not.toHaveBeenCalled();
     });
 
-    it(`Calling .afterEach() will trigger artifacts manager .onAfterEach`, async () => {
-      Detox = require('./Detox');
-
-      detox = new Detox({deviceConfig: validDeviceConfig});
-      detox.artifactsManager.onAfterEach = jest.fn();
-
-      await detox.init();
+    it(`Calling detox.afterEach() should trigger artifactsManager.onAfterEach`, async () => {
       const testSummary = { title: 'test', fullName: 'suite - test', status: 'passed' };
       await detox.afterEach(testSummary);
 
-      expect(detox.artifactsManager.onAfterEach).toHaveBeenCalledWith(testSummary);
+      expect(artifactsManager.onAfterEach).toHaveBeenCalledWith(testSummary);
     });
 
     it(`Calling detox.cleanup() should trigger artifactsManager.afterAll()`, async () => {
-      Detox = require('./Detox');
-
-      detox = new Detox({deviceConfig: validDeviceConfig});
-      await detox.init();
-      detox.artifactsManager.onAfterAll = jest.fn();
       await detox.cleanup();
-      expect(detox.artifactsManager.onAfterAll).toHaveBeenCalledTimes(1);
+      expect(artifactsManager.onAfterAll).toHaveBeenCalledTimes(1);
     });
   });
 });
diff --git a/detox/src/__snapshots__/Detox.test.js.snap b/detox/src/__snapshots__/Detox.test.js.snap
index 4af4cb29d..e82df16fd 100644
--- a/detox/src/__snapshots__/Detox.test.js.snap
+++ b/detox/src/__snapshots__/Detox.test.js.snap
@@ -1,6 +1,6 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`Detox .artifactsManager Calling .beforeEach() and .afterEach() with a deprecated signature will throw an exception 1`] = `
+exports[`Detox .artifactsManager Calling detox.beforeEach() and detox.afterEach() with a deprecated signature will throw an exception 1`] = `
 "DetoxRuntimeError: Invalid test summary was passed to detox.beforeEach(testSummary)
 Expected to get an object of type: { title: string; fullName: string; status: \\"running\\" | \\"passed\\" | \\"failed\\"; }
 
@@ -10,7 +10,7 @@ See the article for the guidance: https://github.com/wix/detox/blob/master/docs/
 testSummary was: 'test'"
 `;
 
-exports[`Detox .artifactsManager Calling .beforeEach() and .afterEach() with a deprecated signature will throw an exception 2`] = `
+exports[`Detox .artifactsManager Calling detox.beforeEach() and detox.afterEach() with a deprecated signature will throw an exception 2`] = `
 "DetoxRuntimeError: Invalid test summary was passed to detox.beforeEach(testSummary)
 Expected to get an object of type: { title: string; fullName: string; status: \\"running\\" | \\"passed\\" | \\"failed\\"; }
 
@@ -20,7 +20,7 @@ See the article for the guidance: https://github.com/wix/detox/blob/master/docs/
 testSummary was: 'test'"
 `;
 
-exports[`Detox .artifactsManager Calling .beforeEach() and .afterEach() with incorrect test status will throw an exception 1`] = `
+exports[`Detox .artifactsManager Calling detox.beforeEach() and detox.afterEach() with incorrect test status will throw an exception 1`] = `
 "DetoxRuntimeError: Invalid test summary status was passed to detox.beforeEach(testSummary). Valid values are: \\"running\\", \\"passed\\", \\"failed\\"
 
 HINT: It seems like you've hit a Detox integration issue with a test runner. You are encouraged to report it in Detox issues on GitHub.
@@ -32,7 +32,7 @@ testSummary was: {
 }"
 `;
 
-exports[`Detox .artifactsManager Calling .beforeEach() and .afterEach() with incorrect test status will throw an exception 2`] = `
+exports[`Detox .artifactsManager Calling detox.beforeEach() and detox.afterEach() with incorrect test status will throw an exception 2`] = `
 "DetoxRuntimeError: Invalid test summary status was passed to detox.beforeEach(testSummary). Valid values are: \\"running\\", \\"passed\\", \\"failed\\"
 
 HINT: It seems like you've hit a Detox integration issue with a test runner. You are encouraged to report it in Detox issues on GitHub.
diff --git a/detox/src/android/expect.test.js b/detox/src/android/expect.test.js
index 8cb080072..1dd77e97f 100644
--- a/detox/src/android/expect.test.js
+++ b/detox/src/android/expect.test.js
@@ -2,8 +2,8 @@ describe('expect', () => {
   let e;
 
   beforeEach(() => {
-    e = require('./expect');
-    e.setInvocationManager(new MockExecutor());
+    const AndroidExpect = require('./expect');
+    e = new AndroidExpect(new MockExecutor());
   });
 
   it(`element by accessibilityLabel`, async () => {
@@ -144,18 +144,6 @@ describe('expect', () => {
     await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow', 0.9));
     await expectToThrow(() => e.element(e.by.id('ScrollView799')).atIndex('NaN'));
   });
-
-  it(`exportGlobals() should export api functions`, async () => {
-    const originalExpect = expect;
-    e.exportGlobals();
-    const newExpect = expect;
-    global.expect = originalExpect;
-
-    expect(newExpect).not.toEqual(originalExpect);
-    expect(element).toBeDefined();
-    expect(waitFor).toBeDefined();
-    expect(by).toBeDefined();
-  });
 });
 
 async function expectToThrow(func) {
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/src/devices/android/ADB.test.js b/detox/src/devices/android/ADB.test.js
index 964244c6f..4759e7987 100644
--- a/detox/src/devices/android/ADB.test.js
+++ b/detox/src/devices/android/ADB.test.js
@@ -1,4 +1,6 @@
 describe('ADB', () => {
+  const mockAndroidSDKPath = '/Android/sdk-mock';
+
   let ADB;
   let adb;
   let EmulatorTelnet;
@@ -7,7 +9,7 @@ describe('ADB', () => {
   beforeEach(() => {
     jest.mock('../../utils/logger');
     jest.mock('../../utils/environment', () => ({
-      getAndroidSDKPath: () => '/dev/null',
+      getAndroidSDKPath: () => mockAndroidSDKPath,
     }));
 
     jest.mock('./EmulatorTelnet');
@@ -27,6 +29,7 @@ describe('ADB', () => {
 
   it(`devices`, async () => {
     await adb.devices();
+    expect(exec).toHaveBeenCalledWith(`${mockAndroidSDKPath}/platform-tools/adb  devices`, { verbosity: 'high' }, undefined, 1);
     expect(exec).toHaveBeenCalledTimes(1);
   });
 
diff --git a/detox/src/exportWrapper.test.js b/detox/src/exportWrapper.test.js
deleted file mode 100644
index 8d1193d4d..000000000
--- a/detox/src/exportWrapper.test.js
+++ /dev/null
@@ -1,68 +0,0 @@
-let exportWrapper;
-let platform;
-let iosExports;
-let androidExports;
-
-describe('exportWrapper', () => {
-  const mockDevice = {method: jest.fn()};
-
-  beforeAll(async() => {
-    jest.mock('./ios/expect');
-    jest.mock('./android/expect');
-    exportWrapper = require('./exportWrapper');
-    platform = require('./platform');
-    iosExports = require('./ios/expect');
-    androidExports = require('./android/expect');
-  });
-
-  afterAll(async() => {
-    jest.unmock('./ios/expect');
-    jest.unmock('./android/expect');
-  });
-
-  it(`proxies ios specific objects`, async () => {
-    const arg1 = 1;
-    const arg2 = 'test';
-
-    platform.set('ios.none', mockDevice);
-    iosExports.by.method = jest.fn();
-
-    exportWrapper.device.method(arg1, arg2);
-    expect(mockDevice.method).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.expect(arg1, arg2);
-    expect(iosExports.expect).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.element(arg1, arg2);
-    expect(iosExports.element).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.waitFor(arg1, arg2);
-    expect(iosExports.waitFor).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.by.method(arg1, arg2);
-    expect(iosExports.by.method).toHaveBeenCalledWith(arg1, arg2);
-  });
-
-  it(`proxies android specific objects`, async () => {
-    const arg1 = 1;
-    const arg2 = 'test';
-
-    platform.set('android.attached', mockDevice);
-    androidExports.by.method = jest.fn();
-
-    exportWrapper.device.method(arg1, arg2);
-    expect(mockDevice.method).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.expect(arg1, arg2);
-    expect(androidExports.expect).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.element(arg1, arg2);
-    expect(androidExports.element).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.waitFor(arg1, arg2);
-    expect(androidExports.waitFor).toHaveBeenCalledWith(arg1, arg2);
-
-    exportWrapper.by.method(arg1, arg2);
-    expect(androidExports.by.method).toHaveBeenCalledWith(arg1, arg2);
-  });
-});
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index 470a6766f..b421008bf 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -1,23 +1,29 @@
 const schemes = require('./configurations.mock');
 describe('index', () => {
   let detox;
-  const mockDevice = {};
-  const mockDetox = {
-    device: mockDevice,
-    init: jest.fn(),
-    cleanup: jest.fn(),
-    beforeEach: jest.fn(),
-    afterEach: jest.fn(),
-  };
+  let mockDevice;
+  let mockDetox;
 
   beforeEach(() => {
+    mockDevice = { launchApp: jest.fn() };
+    mockDetox = {
+      init: jest.fn(() => {
+        mockDetox.device = mockDevice;
+        mockDetox.by = {
+          id: jest.fn(),
+        };
+      }),
+      cleanup: jest.fn(),
+      beforeEach: jest.fn(),
+      afterEach: jest.fn(),
+    };
+
     jest
       .mock('./server/DetoxServer')
       .mock('./devices/Device')
       .mock('./utils/logger')
       .mock('./client/Client')
       .mock('./Detox', () => jest.fn(() => mockDetox))
-      .mock('./platform');
 
     process.env.DETOX_UNIT_TEST = true;
     detox = require('./index');
@@ -30,7 +36,6 @@ describe('index', () => {
       .unmock('./devices/Device')
       .unmock('./client/Client')
       .unmock('./Detox')
-      .unmock('./platform');
   });
 
   it(`throws if there was no config passed`, async () => {
@@ -148,17 +153,6 @@ describe('index', () => {
     expect(exception).toBeDefined();
   });
 
-  it(`sets platform`, async () => {
-    const platform = require('./platform');
-
-    await detox.init(schemes.validOneDeviceNoSession);
-
-    expect(platform.set).toHaveBeenCalledWith(
-      'ios.simulator',
-      mockDevice,
-    );
-  });
-
   it(`initializes detox`, async () => {
     const params = {};
 
@@ -172,6 +166,23 @@ describe('index', () => {
     await detox.cleanup();
   });
 
+  it(`Basic usage with memorized exported objects`, async() => {
+    const { device, by } = detox;
+
+    expect(device.launchApp).toBe(undefined);
+    expect(by.id).toBe(undefined);
+
+    await detox.init(schemes.validOneDeviceNoSession);
+
+    expect(device.launchApp).toEqual(expect.any(Function));
+    expect(by.id).toEqual(expect.any(Function));
+
+    await detox.cleanup();
+
+    expect(device.launchApp).toBe(undefined);
+    expect(by.id).toBe(undefined);
+  });
+
   it(`Basic usage, do not throw an error if cleanup is done twice`, async() => {
     await detox.init(schemes.validOneDeviceNoSession);
     await detox.cleanup();
@@ -182,6 +193,12 @@ describe('index', () => {
     await detox.cleanup();
   });
 
+  it(`if detox.init() fails, detox.cleanup() is called automatically`, async () => {
+    mockDetox.init.mockImplementation(() => { throw new Error('test'); });
+    expect(detox.init(schemes.validOneDeviceNoSession)).rejects.toThrow();
+    expect(mockDetox.cleanup).toHaveBeenCalled();
+  });
+
   it(`beforeEach() should be covered - with detox initialized`, async() => {
     await detox.init(schemes.validOneDeviceNoSession);
     await detox.beforeEach();
diff --git a/detox/src/ios/expect.test.js b/detox/src/ios/expect.test.js
index 174caffa1..1bd61a4d9 100644
--- a/detox/src/ios/expect.test.js
+++ b/detox/src/ios/expect.test.js
@@ -2,8 +2,8 @@ describe('expect', () => {
   let e;
 
   beforeEach(() => {
-    e = require('./expect');
-    e.setInvocationManager(new MockExecutor());
+    const IosExpect = require('./expect');
+    e = new IosExpect(new MockExecutor());
   });
 
   it(`element by accessibilityLabel`, async () => {
@@ -156,18 +156,6 @@ describe('expect', () => {
     await expectToThrow(() => e.element(e.by.type('UIPickerView')).setDatePickerDate(0, 'mm'));
     await expectToThrow(() => e.element(e.by.type('UIPickerView')).setDatePickerDate('something', 0));
   });
-
-  it(`exportGlobals() should export api functions`, async () => {
-    const originalExpect = expect;
-    e.exportGlobals();
-    const newExpect = expect;
-    global.expect = originalExpect;
-
-    expect(newExpect).not.toEqual(originalExpect);
-    expect(element).toBeDefined();
-    expect(waitFor).toBeDefined();
-    expect(by).toBeDefined();
-  });
 });
 
 async function expectToThrow(func) {
diff --git a/detox/src/platform.test.js b/detox/src/platform.test.js
deleted file mode 100644
index ec03c64d1..000000000
--- a/detox/src/platform.test.js
+++ /dev/null
@@ -1,17 +0,0 @@
-const platform = require('./platform');
-
-describe('exportWrapper', () => {
-  const mockDevice = {};
-
-  it(`stores platform specific device`, async () => {
-    platform.set('ios.none', mockDevice);
-
-    expect(platform.get('device')).toBe(mockDevice);
-  });
-
-  it(`stores platform name`, async () => {
-    platform.set('ios.none', mockDevice);
-
-    expect(platform.get('name')).toBe('ios');
-  });
-});
diff --git a/detox/src/utils/__snapshots__/exec.test.js.snap b/detox/src/utils/__snapshots__/exec.test.js.snap
index ca0c275f5..e984f8f46 100644
--- a/detox/src/utils/__snapshots__/exec.test.js.snap
+++ b/detox/src/utils/__snapshots__/exec.test.js.snap
@@ -26,13 +26,19 @@ Array [
 ]
 `;
 
-exports[`exec exec command and fail with timeout 1`] = `
+exports[`exec exec command and fail with error code, report only to debug log if verbosity is low 1`] = `
 Array [
+  Array [
+    Object {
+      "event": "EXEC_CMD",
+    },
+    "bin",
+  ],
   Array [
     Object {
       "event": "EXEC_FAIL",
     },
-    "\\"bin\\" failed with timeout = 1ms, stdout and stderr:
+    "\\"bin\\" failed with code = undefined, stdout and stderr:
 ",
   ],
   Array [
@@ -52,19 +58,13 @@ Array [
 ]
 `;
 
-exports[`exec exec command and silently fail with error code, report only to debug log 1`] = `
+exports[`exec exec command and fail with timeout 1`] = `
 Array [
-  Array [
-    Object {
-      "event": "EXEC_CMD",
-    },
-    "bin",
-  ],
   Array [
     Object {
       "event": "EXEC_FAIL",
     },
-    "\\"bin\\" failed with code = undefined, stdout and stderr:
+    "\\"bin\\" failed with timeout = 1ms, stdout and stderr:
 ",
   ],
   Array [
diff --git a/detox/src/utils/exec.test.js b/detox/src/utils/exec.test.js
index ff277a1be..190f8609b 100644
--- a/detox/src/utils/exec.test.js
+++ b/detox/src/utils/exec.test.js
@@ -61,6 +61,37 @@ describe('exec', () => {
     expect(cpp.exec).toHaveBeenCalledWith(`bin --argument 123`, { timeout: 0 });
   });
 
+  it(`exec command should output success and err logs`, async () => {
+    mockCppSuccessful(cpp);
+    await exec.execWithRetriesAndLogs('bin');
+
+    expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stdout: true }, '"successful result"');
+    expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stderr: true }, 'err');
+  });
+
+  it(`exec command should output a plain success if no output was made`, async () => {
+    const cppResult = {
+      childProcess: {
+        exitCode: 0
+      }
+    };
+    cpp.exec.mockReturnValueOnce(Promise.resolve(cppResult));
+
+    await exec.execWithRetriesAndLogs('bin');
+
+    expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS' }, '');
+    expect(logger.trace).toHaveBeenCalledTimes(1);
+  });
+
+  it(`exec command should output success with high severity if verbosity set to high`, async () => {
+    mockCppSuccessful(cpp);
+    await exec.execWithRetriesAndLogs('bin', { verbosity: 'high' });
+
+    expect(logger.debug).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stdout: true }, '"successful result"');
+    expect(logger.debug).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stderr: true }, 'err');
+    expect(logger.trace).not.toHaveBeenCalledWith(expect.objectContaining({event: 'EXEC_SUCCESS'}), expect.anything());
+  });
+
   it(`exec command with undefined return should throw`, async () => {
     cpp.exec.mockReturnValueOnce(undefined);
     try {
@@ -85,13 +116,13 @@ describe('exec', () => {
     }
   });
 
-  it(`exec command and silently fail with error code, report only to debug log`, async () => {
+  it(`exec command and fail with error code, report only to debug log if verbosity is low`, async () => {
     const errorResult = returnErrorWithValue('error result');
     const rejectedPromise = Promise.reject(errorResult);
     cpp.exec.mockReturnValueOnce(rejectedPromise);
 
     try {
-      await exec.execWithRetriesAndLogs('bin', { silent: true }, '', 1, 1);
+      await exec.execWithRetriesAndLogs('bin', { verbosity: 'low' }, '', 1, 1);
       fail('expected execWithRetriesAndLogs() to throw');
     } catch (object) {
       expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 0 });
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,
+};
diff --git a/detox/test/package.json b/detox/test/package.json
index 13e1925db..eed59e1f4 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -1,6 +1,6 @@
 {
   "name": "detox-test",
-  "version": "12.11.0",
+  "version": "12.11.2",
   "private": true,
   "engines": {
     "node": ">=8.3.0"
@@ -25,7 +25,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.4.5",
-    "detox": "^12.11.0",
+    "detox": "^12.11.2",
     "express": "^4.15.3",
     "jest": "24.6.x",
     "lodash": "^4.14.1",