diff --git a/detox/.gitignore b/detox/.gitignore index 3ede9250ec..ca6517bf4f 100644 --- a/detox/.gitignore +++ b/detox/.gitignore @@ -7,6 +7,8 @@ ios/EarlGrey/Tests/UnitTests/TestRig/Resources ios/EarlGrey/Tests/FunctionalTests/TestRig/Resources Detox.framework Detox.framework.tar +detox-debug.aar +detox-release.aar lib diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle index ce8a1a2ccf..8a634b3125 100644 --- a/detox/android/detox/build.gradle +++ b/detox/android/detox/build.gradle @@ -1,11 +1,11 @@ apply plugin: 'com.android.library' android { - compileSdkVersion 25 - buildToolsVersion "25.0.1" + compileSdkVersion 23 + buildToolsVersion "23.0.1" defaultConfig { minSdkVersion 16 - targetSdkVersion 25 + targetSdkVersion 22 versionCode 1 versionName "1.0" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" @@ -16,16 +16,20 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } + publishNonDefault true } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) - compile project(':invoke') - compile 'com.android.support:appcompat-v7:25.0.1' - compile 'com.squareup.okhttp3:okhttp:3.5.0' + //compile project(':invoke') + compile "com.android.support:appcompat-v7:23.0.1" + compile 'com.squareup.okhttp3:okhttp:3.4.1' + compile 'com.squareup.okhttp3:okhttp-ws:3.4.1' testCompile 'junit:junit:4.12' - compile('com.android.support.test.espresso:espresso-core:2.2.2') + compile('com.android.support.test.espresso:espresso-core:2.2.2', { + exclude group: 'com.google.code.findbugs' + }) androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { exclude group: 'com.android.support', module: 'support-annotations' }) -} \ No newline at end of file +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/Delegator.java b/detox/android/detox/src/main/java/com/wix/detox/Delegator.java new file mode 100644 index 0000000000..93afdce25c --- /dev/null +++ b/detox/android/detox/src/main/java/com/wix/detox/Delegator.java @@ -0,0 +1,108 @@ +package com.wix.detox; + +import org.joor.Reflect; +import org.joor.ReflectException; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; + +/** + * Created by simonracz on 29/05/2017. + */ + +/** + *
+ * Helper class for InvocationHandlers, which delegates equals, hashCode and toString + * calls to Object. + *
+ * + *+ * Copied from here + * Delegator + *
+ */ +public class Delegator implements InvocationHandler { + + private static Method hashCodeMethod; + private static Method equalsMethod; + private static Method toStringMethod; + static { + try { + hashCodeMethod = Object.class.getMethod("hashCode", null); + equalsMethod = + Object.class.getMethod("equals", new Class[] { Object.class }); + toStringMethod = Object.class.getMethod("toString", null); + } catch (NoSuchMethodException e) { + throw new NoSuchMethodError(e.getMessage()); + } + } + + private Class[] interfaces; + private Object[] delegates; + + public Delegator(Class[] interfaces, Object[] delegates) { + this.interfaces = (Class[]) interfaces.clone(); + this.delegates = (Object[]) delegates.clone(); + } + + public Object invoke(Object proxy, Method m, Object[] args) + throws Throwable + { + Class declaringClass = m.getDeclaringClass(); + + if (declaringClass == Object.class) { + if (m.equals(hashCodeMethod)) { + return proxyHashCode(proxy); + } else if (m.equals(equalsMethod)) { + return proxyEquals(proxy, args[0]); + } else if (m.equals(toStringMethod)) { + return proxyToString(proxy); + } else { + throw new InternalError( + "unexpected Object method dispatched: " + m); + } + } else { + for (int i = 0; i < interfaces.length; i++) { + if (declaringClass.isAssignableFrom(interfaces[i])) { + try { + return Reflect.on(delegates[i]).call(m.getName(), args).get(); + } catch (ReflectException e) { + throw e.getCause(); + } + } + } + + return invokeNotDelegated(proxy, m, args); + } + } + + // Simple workaround for a deeply rooted issue regarding Proxy classes + public Object invokeAsString(String methodName) throws ReflectException { + return Reflect.on(delegates[0]).call(methodName).get(); + } + + // Simple workaround for a deeply rooted issue regarding Proxy classes + public Object invokeAsString(String methodName, Object[] args) throws ReflectException { + return Reflect.on(delegates[0]).call(methodName, args).get(); + } + + protected Object invokeNotDelegated(Object proxy, Method m, + Object[] args) + throws Throwable + { + throw new InternalError("unexpected method dispatched: " + m); + } + + protected Integer proxyHashCode(Object proxy) { + return new Integer(System.identityHashCode(proxy)); + } + + protected Boolean proxyEquals(Object proxy, Object other) { + return (proxy == other ? Boolean.TRUE : Boolean.FALSE); + } + + protected String proxyToString(Object proxy) { + return proxy.getClass().getName() + '@' + + Integer.toHexString(proxy.hashCode()); + } +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/Detox.java b/detox/android/detox/src/main/java/com/wix/detox/Detox.java new file mode 100644 index 0000000000..1946d9164a --- /dev/null +++ b/detox/android/detox/src/main/java/com/wix/detox/Detox.java @@ -0,0 +1,124 @@ +package com.wix.detox; + +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.support.test.InstrumentationRegistry; + +/** + *Static class.
+ * + *To start Detox tests, call runTests() from a JUnit test. + * This test must use AndroidJUnitTestRunner or a subclass of it, as Detox uses Espresso internally. + * All non-standard async code must be wrapped in an Espresso + * IdlingResource.
+ * + * Example usage + *{@code + *@literal @runWith(AndroidJUnit4.class) + *@literal @LargeTest + * public class DetoxTest { + * @literal @Rule + * //The Activity that controls React Native. + * public ActivityTestRule+ * + *mActivityRule = new ActivityTestRule(MainActivity.class); + * + * @literal @Before + * public void setUpCustomEspressoIdlingResources() { + * // set up your own custom Espresso resources here + * } + * + * @literal @Test + * public void runDetoxTests() { + * Detox.runTests(); + * } + * }}
Two required parameters are detoxServer and detoxSessionId. These
+ * must be provided either by Gradle.
+ *
+ *
+ *
{@code + * android { + * defaultConfig { + * testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + * testInstrumentationRunnerArguments = [ + * 'detoxServer': 'ws://10.0.2.2:8001', + * 'detoxSessionId': '1' + * ] + * } + * }} + *+ * + * Or through command line, e.g
{@code adb shell am instrument -w -e detoxServer ws://localhost:8001 -e detoxSessionId + * 1 com.example/android.support.test.runner.AndroidJUnitRunner}+ * + *
These are automatically set using, + *
{@code detox test}+ * + *
If not set, then Detox tests are no ops. So it's safe to mix it with other tests.
+ */ +public final class Detox { + private Detox() { + // static class + } + + /** + *+ * Call this method from a JUnit test to invoke detox tests. + *
+ * + *+ * In case you have a non-standard React Native application, consider using + * {@link Detox#runTests(Object)}. + *
+ */ + public static void runTests() { + Object appContext = InstrumentationRegistry.getTargetContext().getApplicationContext(); + runTests(appContext); + } + + /** + *+ * Call this method only if you have a React Native application and it + * doesn't implement ReactApplication. + *
+ * + * Call {@link Detox#runTests()} in every other case. + * + *+ * The only requirement is that the passed in object must have + * a method with the signature + *
{@code ReactNativeHost getReactNativeHost();}+ * + * + * @param reactActivityDelegate an object that has a {@code getReactNativeHost()} method + */ + public static void runTests(@NonNull final Object reactActivityDelegate) { + // Kicks off another thread and attaches a Looper to that. + // The goal is to keep the test thread intact, + // as Loopers can't run on a thread twice. + Thread t = new Thread(new Runnable() { + @Override + public void run() { + Looper.prepare(); + Handler handler = new Handler(); + handler.post(new Runnable() { + @Override + public void run() { + DetoxManager detoxManager = new DetoxManager(reactActivityDelegate); + detoxManager.start(); + } + }); + Looper.loop(); + } + }, "com.wix.detox.manager"); + t.start(); + try { + t.join(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Got interrupted", e); + } + } +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java index edc3c284f1..d5439b9bcb 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java +++ b/detox/android/detox/src/main/java/com/wix/detox/DetoxManager.java @@ -1,62 +1,184 @@ package com.wix.detox; +import android.os.Bundle; +import android.os.Handler; import android.os.Looper; +import android.support.annotation.NonNull; +import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.Espresso; +import android.support.test.espresso.EspressoException; import android.util.Log; import java.util.Collections; import java.util.Map; +import static android.support.test.espresso.action.ViewActions.click; +import static android.support.test.espresso.assertion.ViewAssertions.matches; +import static android.support.test.espresso.matcher.ViewMatchers.isDisplayed; +import static android.support.test.espresso.matcher.ViewMatchers.withTagValue; +import static android.support.test.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.not; + + /** * Created by rotemm on 04/01/2017. */ -public class DetoxManager implements WebSocketClient.ActionHandler { +class DetoxManager implements WebSocketClient.ActionHandler { private static final String LOG_TAG = "DetoxManager"; - private WebSocketClient client; - private TestRunner testRunner; - private Looper looper; - public DetoxManager() { + private final static String DETOX_SERVER_ARG_KEY = "detoxServer"; + private final static String DETOX_SESSION_ID_ARG_KEY = "detoxSessionId"; + private String detoxServerUrl = null; + private String detoxSessionId = null; + + private WebSocketClient wsClient; + // private TestRunner testRunner; + private Handler handler; - Looper.prepare(); + private Object reactNativeHostHolder = null; - testRunner = new TestRunner(); - connectToServer(); + DetoxManager(@NonNull Object reactNativeHostHolder) { + this.reactNativeHostHolder = reactNativeHostHolder; + handler = new Handler(); + + Bundle arguments = InstrumentationRegistry.getArguments(); + detoxServerUrl = arguments.getString(DETOX_SERVER_ARG_KEY); + detoxSessionId = arguments.getString(DETOX_SESSION_ID_ARG_KEY); + + if (detoxServerUrl == null || detoxSessionId == null) { + Log.i(LOG_TAG, "Missing arguments : detoxServer and/or detoxSession. Detox quits."); + stop(); + return; + } - looper = Looper.myLooper(); - Looper.loop(); + Log.i(LOG_TAG, "DetoxServerUrl : " + detoxServerUrl); + Log.i(LOG_TAG, "DetoxSessionId : " + detoxSessionId); } - private void connectToServer() { - client = new WebSocketClient(this); - client.connectToServer("test"); + void start() { + if (detoxServerUrl != null && detoxSessionId != null) { + if (ReactNativeSupport.isReactNativeApp()) { + ReactNativeSupport.waitForReactNativeLoad(reactNativeHostHolder); + } + // testRunner = new TestRunner(this); + wsClient = new WebSocketClient(this); + wsClient.connectToServer(detoxServerUrl, detoxSessionId); + } } + void stop() { + Log.i(LOG_TAG, "Stopping Detox."); + handler.postAtFrontOfQueue(new Runnable() { + @Override + public void run() { + // TODO + // Close the websocket + ReactNativeSupport.removeEspressoIdlingResources(reactNativeHostHolder); + Looper.myLooper().quit(); + } + }); + } @Override - public void onAction(String type, Map params) { - Log.d(LOG_TAG, "onAction: type: " + type + " params: " + params); - switch (type) { - case "invoke": - break; - case "isReady": - break; - case "cleanup": - break; - } - + public void onAction(final String type, Map params) { + Log.i(LOG_TAG, "onAction: type: " + type + " params: " + params); + handler.post(new Runnable() { + @Override + public void run() { + switch (type) { + case "invoke": + /* + try { + Espresso.onView(withTagValue(is((Object)"hello_button"))).check(matches(isDisplayed())); + } catch (RuntimeException e) { + if (e instanceof EspressoException) { + Log.i(LOG_TAG, "Test exception", e); + } else { + Log.e(LOG_TAG, "Exception", e); + } + stop(); + } + */ + break; + case "isReady": + // It's always ready, because reload, waitForRn are both synchronous. + wsClient.sendAction("ready", Collections.emptyMap()); + break; + case "cleanup": + wsClient.sendAction("cleanupDone", Collections.emptyMap()); + stop(); + break; + case "reactNativeReload": + ReactNativeSupport.reloadApp(reactNativeHostHolder); + break; + // TODO + // Remove these test* commands later. + case "testInvoke1": + try { + Espresso.onView(withTagValue(is((Object)"hello_button"))).check(matches(isDisplayed())); + } catch (RuntimeException e) { + if (e instanceof EspressoException) { + Log.i(LOG_TAG, "Test exception", e); + wsClient.sendAction("TEST_FAIL", Collections.emptyMap()); + } else { + wsClient.sendAction("EXCEPTION", Collections.emptyMap()); + Log.e(LOG_TAG, "Exception", e); + } + stop(); + break; + } + wsClient.sendAction("TEST_OK", Collections.emptyMap()); + break; + case "testInvokeNeg1": + try { + Espresso.onView(withTagValue(is((Object)"hello_button"))).check(matches(not(isDisplayed()))); + } catch (RuntimeException e) { + if (e instanceof EspressoException) { + Log.i(LOG_TAG, "Test exception", e); + wsClient.sendAction("TEST_FAIL", Collections.emptyMap()); + } else { + wsClient.sendAction("EXCEPTION", Collections.emptyMap()); + Log.e(LOG_TAG, "Exception", e); + } + stop(); + break; + } + wsClient.sendAction("TEST_OK", Collections.emptyMap()); + break; + case "testPush": + Espresso.onView(withTagValue(is((Object) "hello_button"))).perform(click()); + break; + case "testInvoke2": + try { + Espresso.onView(withText("Hello!!!")).check(matches(isDisplayed())); + } catch (RuntimeException e) { + if (e instanceof EspressoException) { + Log.i(LOG_TAG, "Test exception", e); + wsClient.sendAction("TEST_FAIL", Collections.emptyMap()); + } else { + wsClient.sendAction("EXCEPTION", Collections.emptyMap()); + Log.e(LOG_TAG, "Exception", e); + } + stop(); + break; + } + wsClient.sendAction("TEST_OK", Collections.emptyMap()); + break; + } + } + }); } @Override public void onConnect() { - client.sendAction("ready", Collections.emptyMap()); + wsClient.sendAction("ready", Collections.emptyMap()); } @Override public void onClosed() { - if (looper != null) { - looper.quit(); - } + stop(); } } diff --git a/detox/android/detox/src/main/java/com/wix/detox/ReactInstanceEventListenerProxy.java b/detox/android/detox/src/main/java/com/wix/detox/ReactInstanceEventListenerProxy.java new file mode 100644 index 0000000000..a9314a75fc --- /dev/null +++ b/detox/android/detox/src/main/java/com/wix/detox/ReactInstanceEventListenerProxy.java @@ -0,0 +1,9 @@ +package com.wix.detox; + +/** + * Created by simonracz on 29/05/2017. + */ + +public interface ReactInstanceEventListenerProxy { + void onReactContextInitialized(Object reactContext); +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/ReactNativeSupport.java b/detox/android/detox/src/main/java/com/wix/detox/ReactNativeSupport.java new file mode 100644 index 0000000000..e6bc079a6a --- /dev/null +++ b/detox/android/detox/src/main/java/com/wix/detox/ReactNativeSupport.java @@ -0,0 +1,339 @@ +package com.wix.detox; + +import android.os.Handler; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.support.test.InstrumentationRegistry; +import android.support.test.espresso.Espresso; +import android.support.test.espresso.IdlingResource; +import android.util.Log; + +import com.wix.detox.espresso.ReactBridgeIdlingResource; +import com.wix.detox.espresso.ReactNativeTimersIdlingResource; + +import org.joor.Reflect; +import org.joor.ReflectException; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashSet; + +/** + * Created by simonracz on 15/05/2017. + */ + +class ReactNativeSupport { + private static final String LOG_TAG = "Detox"; + private static final String METHOD_GET_RN_HOST = "getReactNativeHost"; + private static final String METHOD_GET_INSTANCE_MANAGER = "getReactInstanceManager"; + private final static String METHOD_GET_CATALYST_INSTANCE = "getCatalystInstance"; + private final static String METHOD_ADD_DEBUG_BRIDGE_LISTENER = "addBridgeIdleDebugListener"; + private final static String METHOD_REMOVE_DEBUG_BRIDGE_LISTENER = "removeBridgeIdleDebugListener"; + private static final String METHOD_RECREATE_RN_CONTEXT = "recreateReactContextInBackground"; + private static final String METHOD_GET_REACT_CONTEXT = "getCurrentReactContext"; + private static final String METHOD_ADD_REACT_INSTANCE_LISTENER = "addReactInstanceEventListener"; + private static final String METHOD_REMOVE_REACT_INSTANCE_LISTENER = "removeReactInstanceEventListener"; + private static final String INTERFACE_REACT_INSTANCE_EVENT_LISTENER = + "com.facebook.react.ReactInstanceManager$ReactInstanceEventListener"; + private static final String METHOD_HAS_STARTED_CREAT_CTX = "hasStartedCreatingInitialContext"; + private static final String METHOD_CREAT_RN_CTX_IN_BG = "createReactContextInBackground"; + + private static final String INTERFACE_BRIDGE_IDLE_DEBUG_LISTENER = + "com.facebook.react.bridge.NotThreadSafeBridgeIdleDebugListener"; + + private static final String FIELD_UI_MSG_QUEUE = "mUiMessageQueueThread"; + private static final String FIELD_UI_BG_MSG_QUEUE = "mUiBackgroundMessageQueueThread"; + private static final String FIELD_NATIVE_MODULES_MSG_QUEUE = "mNativeModulesMessageQueueThread"; + private static final String FIELD_JS_MSG_QUEUE = "mJSMessageQueueThread"; + private static final String METHOD_GET_LOOPER = "getLooper"; + + // Espresso has a public method to register Loopers. + // BUT, they don't give you back a handle to them. + // Therefore you can't unregister them. + // We create the LooperIdlingResources by ourselves to keep a handle to them. + private static final String CLASS_ESPRESSO_LOOPER_IDLING_RESOURCE = + "android.support.test.espresso.base.LooperIdlingResource"; + + private ReactNativeSupport() { + // static class + } + + static boolean isReactNativeApp() { + Class> found = null; + try { + found = Class.forName("com.facebook.react.ReactApplication"); + } catch (ClassNotFoundException e) { + return false; + } + return (found != null); + } + + /** + * Returns the instanceManager using reflection. + * + * @param reactNativeHostHolder the object that has a getReactNativeHost() method + * @return Returns the instanceManager as an Object or null + */ + private static Object getInstanceManager(@NonNull Object reactNativeHostHolder) { + Object instanceManager = null; + try { + instanceManager = Reflect.on(reactNativeHostHolder) + .call(METHOD_GET_RN_HOST) + .call(METHOD_GET_INSTANCE_MANAGER) + .get(); + } catch (ReflectException e) { + Log.e(LOG_TAG, "Problem calling getInstanceManager()", e.getCause()); + } + + return instanceManager; + } + + /** + *
+ * Reloads the React Native application. + *
+ * + *+ * It is a lot faster to reload a React Native application this way, + * than to reload the whole Activity or Application. + *
+ * + * @param reactNativeHostHolder the object that has a getReactNativeHost() method + */ + static void reloadApp(@NonNull Object reactNativeHostHolder) { + if (!isReactNativeApp()) { + return; + } + Log.i(LOG_TAG, "Reloading React Native"); + + removeEspressoIdlingResources(reactNativeHostHolder); + + final Object instanceManager = getInstanceManager(reactNativeHostHolder); + if (instanceManager == null) { + return; + } + + // Must be called on the UI thread! + InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() { + @Override + public void run() { + try { + Reflect.on(instanceManager).call(METHOD_RECREATE_RN_CONTEXT); + } catch (ReflectException e) { + Log.e(LOG_TAG, "Problem calling reloadApp()", e.getCause()); + } + } + }); + + waitForReactNativeLoad(reactNativeHostHolder); + } + + /** + *+ * The rendering of RN views are NOT guaranteed to be finished after this call. + * However, calling Espresso methods are safe from this point. + *
+ */ + static void waitForReactNativeLoad(@NonNull Object reactNativeHostHolder) { + if (!isReactNativeApp()) { + return; + } + + final Object instanceManager = getInstanceManager(reactNativeHostHolder); + if (instanceManager == null) { + return; + } + + final Object[] reactContextHolder = new Object[1]; + reactContextHolder[0] = Reflect.on(instanceManager).call(METHOD_GET_REACT_CONTEXT).get(); + if (reactContextHolder[0] == null) { + Class> listener; + try { + listener = Class.forName(INTERFACE_REACT_INSTANCE_EVENT_LISTENER); + } catch (ClassNotFoundException e) { + Log.e(LOG_TAG, "Can't find ReactInstanceEventListener()", e); + return; + } + synchronized (instanceManager) { + Class[] proxyInterfaces = new Class[]{listener}; + final Proxy[] proxyHolder = new Proxy[1]; + final Delegator delegator = new Delegator(proxyInterfaces, new Object[] {new ReactInstanceEventListenerProxy() { + @Override + public void onReactContextInitialized(Object reactContext) { + Log.i(LOG_TAG, "Got react context through listener."); + reactContextHolder[0] = reactContext; + Reflect.on(instanceManager).call(METHOD_REMOVE_REACT_INSTANCE_LISTENER, (Object) proxyHolder[0]); + synchronized (instanceManager) { + instanceManager.notify(); + } + } + }}); + proxyHolder[0] = (Proxy) Proxy.newProxyInstance( + listener.getClassLoader(), + proxyInterfaces, + delegator); + Reflect.on(instanceManager).call( + METHOD_ADD_REACT_INSTANCE_LISTENER, + proxyHolder[0]); + if (!(boolean) Reflect.on(instanceManager).call(METHOD_HAS_STARTED_CREAT_CTX).get()) { + // Must be called on the UI thread! + Handler handler = new Handler(InstrumentationRegistry.getTargetContext().getMainLooper()); + handler.post(new Runnable() { + @Override + public void run() { + try { + Reflect.on(instanceManager).call(METHOD_CREAT_RN_CTX_IN_BG); + } catch (ReflectException e) { + Log.e(LOG_TAG, "Problem calling createReactContextInBackground()", + e.getCause()); + } + } + }); + + } + while (true) { + try { + instanceManager.wait(); + break; + } catch (InterruptedException e) { + Log.i(LOG_TAG, "Got interrupted.", e); + // go on + } + } + } + } + + // We could call waitForBridgeAndUIIDle(..) here, but + // Espresso will do that for us either way. + setupEspressoIdlingResources(reactNativeHostHolder, reactContextHolder[0]); + } + + private static Object bridgeIdleSignaler = null; + + private static void createBridgeIdleSignaler() { + Class> bridgeIdleDebugListener = null; + try { + bridgeIdleDebugListener = Class.forName(INTERFACE_BRIDGE_IDLE_DEBUG_LISTENER); + } catch (ClassNotFoundException e) { + Log.e(LOG_TAG, "Can't find ReactBridgeIdleSignaler()", e); + return; + } + + rnBridgeIdlingResource = new ReactBridgeIdlingResource(); + + Class[] proxyInterfaces = new Class[]{bridgeIdleDebugListener}; + bridgeIdleSignaler = Proxy.newProxyInstance( + bridgeIdleDebugListener.getClassLoader(), + proxyInterfaces, + new Delegator(proxyInterfaces, new Object[] { rnBridgeIdlingResource }) + ); + } + + private static ReactNativeTimersIdlingResource rnTimerIdlingResource = null; + private static ReactBridgeIdlingResource rnBridgeIdlingResource = null; + + private static void setupEspressoIdlingResources( + @NonNull Object reactNativeHostHolder, + @NonNull Object reactContext) { + removeEspressoIdlingResources(reactNativeHostHolder, reactContext); + Log.i(LOG_TAG, "Setting up Espresso Idling Resources for React Native."); + + setupReactNativeQueueInterrogators(reactContext); + + createBridgeIdleSignaler(); + Reflect.on(reactContext) + .call(METHOD_GET_CATALYST_INSTANCE) + .call(METHOD_ADD_DEBUG_BRIDGE_LISTENER, bridgeIdleSignaler); + + rnTimerIdlingResource = new ReactNativeTimersIdlingResource(reactContext); + + Espresso.registerIdlingResources(rnTimerIdlingResource, rnBridgeIdlingResource); + } + + private static ArrayList+ * IdlingResource for Espresso, which monitors the traffic of + * React Native's JS bridge. + *
+ */ +public class ReactBridgeIdlingResource implements IdlingResource { + private static final String LOG_TAG = "Detox"; + + private AtomicBoolean idleNow = new AtomicBoolean(true); + private ResourceCallback callback = null; + + @Override + public String getName() { + return ReactBridgeIdlingResource.class.getName(); + } + + @Override + public boolean isIdleNow() { + boolean ret = idleNow.get(); + Log.i(LOG_TAG, "JS Bridge is idle : " + String.valueOf(ret)); + return ret; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + this.callback = callback; + } + + // Proxy calls it + public void onTransitionToBridgeIdle() { + idleNow.set(true); + if (callback != null) { + callback.onTransitionToIdle(); + } + Log.i(LOG_TAG, "JS Bridge transitions to idle."); + } + + //Proxy calls it + public void onTransitionToBridgeBusy() { + idleNow.set(false); + Log.i(LOG_TAG, "JS Bridge transitions to busy."); + } +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactNativeTimersIdlingResource.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactNativeTimersIdlingResource.java new file mode 100644 index 0000000000..c18ec83bc7 --- /dev/null +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactNativeTimersIdlingResource.java @@ -0,0 +1,130 @@ +package com.wix.detox.espresso; + +import android.support.annotation.NonNull; +import android.support.test.espresso.IdlingResource; +import android.util.Log; + +import org.joor.Reflect; +import org.joor.ReflectException; + +import java.util.PriorityQueue; + +/** + * Created by simonracz on 23/05/2017. + */ + +/** + *+ * Espresso IdlingResource for React Native js timers. + *
+ * + *+ * Hooks up to React Native internals, grabbing the Timers from it. + *
+ *+ * This resource is considered idle if the Timers priority queue is empty or + * the one scheduled the soonest is still too far in the future. + *
+ */ +public class ReactNativeTimersIdlingResource implements IdlingResource { + private static final String LOG_TAG = "Detox"; + + private final static String CLASS_TIMING = "com.facebook.react.modules.core.Timing"; + private final static String CLASS_TIMER = "com.facebook.react.modules.core.Timing$Timer"; + private final static String METHOD_HAS_CATALYST_INSTANCE = "hasActiveCatalystInstance"; + private final static String METHOD_GET_NATIVE_MODULE = "getNativeModule"; + private final static String METHOD_HAS_NATIVE_MODULE = "hasNativeModule"; + private final static String FIELD_TIMERS = "mTimers"; + private final static String FIELD_TARGET_TIME = "mTargetTime"; + + private static final long LOOK_AHEAD_MS = 15; // like Espresso + + private ResourceCallback callback = null; + private Object reactContext = null; + + public ReactNativeTimersIdlingResource(@NonNull Object reactContext) { + super(); + this.reactContext = reactContext; + } + + @Override + public String getName() { + return ReactNativeTimersIdlingResource.class.getName(); + } + + @Override + public boolean isIdleNow() { + // This is not a proper Espresso IdlingResource yet, + // as it is driven by the isIdleNow() method. + // This will be marked as a Racy Resource internally. + // It'll cause no problem though. + Class> timingClass = null; + Class> timerClass = null; + try { + timingClass = Class.forName(CLASS_TIMING); + timerClass = Class.forName(CLASS_TIMER); + } catch (ClassNotFoundException e) { + Log.e(LOG_TAG, "Can't find Timing or Timing$Timer classes"); + if (callback != null) { + callback.onTransitionToIdle(); + } + return true; + } + + try { + // reactContext.hasActiveCatalystInstance() should be always true here + // if called right after onReactContextInitialized(...) + if (!(boolean)Reflect.on(reactContext).call(METHOD_HAS_CATALYST_INSTANCE).get()) { + Log.e(LOG_TAG, "No active CatalystInstance. Should never see this."); + return false; + } + + if (!(boolean)Reflect.on(reactContext).call(METHOD_HAS_NATIVE_MODULE, timingClass).get()) { + Log.e(LOG_TAG, "Can't find Timing NativeModule"); + if (callback != null) { + callback.onTransitionToIdle(); + } + return true; + } + + Object timingModule = Reflect.on(reactContext).call(METHOD_GET_NATIVE_MODULE, timingClass).get(); + PriorityQueue> timers = Reflect.on(timingModule).field(FIELD_TIMERS).get(); + if (timers.isEmpty()) { + if (callback != null) { + callback.onTransitionToIdle(); + } + return true; + } + + Log.i(LOG_TAG, "Num of Timers : " + timers.size()); + + long targetTime = Reflect.on(timers.peek()).field(FIELD_TARGET_TIME).get(); + long currentTimeMS = System.nanoTime() / 1000000; + + Log.i(LOG_TAG, "targetTime " + targetTime + " currentTime " + currentTimeMS); + + if (targetTime - currentTimeMS > LOOK_AHEAD_MS || targetTime < currentTimeMS) { + // Timer is too far in the future. Mark it as OK for now. + // This is similar to what Espresso does internally. + if (callback != null) { + callback.onTransitionToIdle(); + } + return true; + } + + return false; + } catch (ReflectException e) { + Log.e(LOG_TAG, "Can't set up RN timer listener", e.getCause()); + } + + if (callback != null) { + callback.onTransitionToIdle(); + } + return true; + } + + @Override + public void registerIdleTransitionCallback(ResourceCallback callback) { + this.callback = callback; + } +} diff --git a/detox/android/detox/src/main/java/org/joor/Reflect.java b/detox/android/detox/src/main/java/org/joor/Reflect.java new file mode 100644 index 0000000000..cfea6320a7 --- /dev/null +++ b/detox/android/detox/src/main/java/org/joor/Reflect.java @@ -0,0 +1,832 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.joor; + +import java.lang.reflect.AccessibleObject; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * A wrapper for an {@link Object} or {@link Class} upon which reflective calls + * can be made. + *
+ * An example of using
+ * This is the same as calling
+ * This is the same as calling
+ *
+ * Use this when you want to access static fields and methods on a
+ * {@link Class} object, or as a basis for constructing objects of that
+ * class using {@link #create(Object...)}
+ *
+ * @param clazz The class to be wrapped
+ * @return A wrapped class object, to be used for further reflection.
+ */
+ public static Reflect on(Class> clazz) {
+ return new Reflect(clazz);
+ }
+
+ /**
+ * Wrap an object.
+ *
+ * Use this when you want to access instance fields and methods on any
+ * {@link Object}
+ *
+ * @param object The object to be wrapped
+ * @return A wrapped object, to be used for further reflection.
+ */
+ public static Reflect on(Object object) {
+ return new Reflect(object == null ? Object.class : object.getClass(), object);
+ }
+
+ private static Reflect on(Class> type, Object object) {
+ return new Reflect(type, object);
+ }
+
+ /**
+ * Conveniently render an {@link AccessibleObject} accessible.
+ *
+ * To prevent {@link SecurityException}, this is only done if the argument
+ * object and its declaring class are non-public.
+ *
+ * @param accessible The object to render accessible
+ * @return The argument object rendered accessible
+ */
+ public static
+ * This is roughly equivalent to {@link Field#set(Object, Object)}. If the
+ * wrapped object is a {@link Class}, then this will set a value to a static
+ * member field. If the wrapped object is any other {@link Object}, then
+ * this will set a value to an instance member field.
+ *
+ * This method is also capable of setting the value of (static) final
+ * fields. This may be convenient in situations where no
+ * {@link SecurityManager} is expected to prevent this, but do note that
+ * (especially static) final fields may already have been inlined by the
+ * javac and/or JIT and relevant code deleted from the runtime verison of
+ * your program, so setting these fields might not have any effect on your
+ * execution.
+ *
+ * For restrictions of usage regarding setting values on final fields check:
+ * http://stackoverflow.com/questions/3301635/change-private-static-final-field-using-java-reflection
+ * ... and http://pveentjer.blogspot.co.at/2017/01/final-static-boolean-jit.html
+ *
+ * @param name The field name
+ * @param value The new field value
+ * @return The same wrapped object, to be used for further reflection.
+ * @throws ReflectException If any reflection exception occurred.
+ */
+ public Reflect set(String name, Object value) throws ReflectException {
+ try {
+ Field field = field0(name);
+ if ((field.getModifiers() & Modifier.FINAL) == Modifier.FINAL) {
+ Field modifiersField = Field.class.getDeclaredField("modifiers");
+ modifiersField.setAccessible(true);
+ modifiersField.setInt(field, field.getModifiers() & ~Modifier.FINAL);
+ }
+ field.set(object, unwrap(value));
+ return this;
+ }
+ catch (Exception e) {
+ throw new ReflectException(e);
+ }
+ }
+
+ /**
+ * Get a field value.
+ *
+ * This is roughly equivalent to {@link Field#get(Object)}. If the wrapped
+ * object is a {@link Class}, then this will get a value from a static
+ * member field. If the wrapped object is any other {@link Object}, then
+ * this will get a value from an instance member field.
+ *
+ * If you want to "navigate" to a wrapped version of the field, use
+ * {@link #field(String)} instead.
+ *
+ * @param name The field name
+ * @return The field value
+ * @throws ReflectException If any reflection exception occurred.
+ * @see #field(String)
+ */
+ public
+ * This is roughly equivalent to {@link Field#get(Object)}. If the wrapped
+ * object is a {@link Class}, then this will wrap a static member field. If
+ * the wrapped object is any other {@link Object}, then this wrap an
+ * instance member field.
+ *
+ * @param name The field name
+ * @return The wrapped field
+ * @throws ReflectException If any reflection exception occurred.
+ */
+ public Reflect field(String name) throws ReflectException {
+ try {
+ Field field = field0(name);
+ return on(field.getType(), field.get(object));
+ }
+ catch (Exception e) {
+ throw new ReflectException(e);
+ }
+ }
+
+ private Field field0(String name) throws ReflectException {
+ Class> t = type();
+
+ // Try getting a public field
+ try {
+ return accessible(t.getField(name));
+ }
+
+ // Try again, getting a non-public field
+ catch (NoSuchFieldException e) {
+ do {
+ try {
+ return accessible(t.getDeclaredField(name));
+ }
+ catch (NoSuchFieldException ignore) {}
+
+ t = t.getSuperclass();
+ }
+ while (t != null);
+
+ throw new ReflectException(e);
+ }
+ }
+
+ /**
+ * Get a Map containing field names and wrapped values for the fields'
+ * values.
+ *
+ * If the wrapped object is a {@link Class}, then this will return static
+ * fields. If the wrapped object is any other {@link Object}, then this will
+ * return instance fields.
+ *
+ * These two calls are equivalent
+ * This is a convenience method for calling
+ *
+ * This is roughly equivalent to {@link Method#invoke(Object, Object...)}.
+ * If the wrapped object is a {@link Class}, then this will invoke a static
+ * method. If the wrapped object is any other {@link Object}, then this will
+ * invoke an instance method.
+ *
+ * Just like {@link Method#invoke(Object, Object...)}, this will try to wrap
+ * primitive types or unwrap primitive type wrappers if applicable. If
+ * several methods are applicable, by that rule, the first one encountered
+ * is called. i.e. when calling
+ * The best matching method is searched for with the following strategy:
+ *
+ * If a public method is found in the class hierarchy, this method is returned.
+ * Otherwise a private method with the exact same signature is returned.
+ * If no exact match could be found, we let the {@code NoSuchMethodException} pass through.
+ */
+ private Method exactMethod(String name, Class>[] types) throws NoSuchMethodException {
+ Class> t = type();
+
+ // first priority: find a public method with exact signature match in class hierarchy
+ try {
+ return t.getMethod(name, types);
+ }
+
+ // second priority: find a private method with exact signature match on declaring class
+ catch (NoSuchMethodException e) {
+ do {
+ try {
+ return t.getDeclaredMethod(name, types);
+ }
+ catch (NoSuchMethodException ignore) {}
+
+ t = t.getSuperclass();
+ }
+ while (t != null);
+
+ throw new NoSuchMethodException();
+ }
+ }
+
+ /**
+ * Searches a method with a similar signature as desired using
+ * {@link #isSimilarSignature(java.lang.reflect.Method, String, Class[])}.
+ *
+ * First public methods are searched in the class hierarchy, then private
+ * methods on the declaring class. If a method could be found, it is
+ * returned, otherwise a {@code NoSuchMethodException} is thrown.
+ */
+ private Method similarMethod(String name, Class>[] types) throws NoSuchMethodException {
+ Class> t = type();
+
+ // first priority: find a public method with a "similar" signature in class hierarchy
+ // similar interpreted in when primitive argument types are converted to their wrappers
+ for (Method method : t.getMethods()) {
+ if (isSimilarSignature(method, name, types)) {
+ return method;
+ }
+ }
+
+ // second priority: find a non-public method with a "similar" signature on declaring class
+ do {
+ for (Method method : t.getDeclaredMethods()) {
+ if (isSimilarSignature(method, name, types)) {
+ return method;
+ }
+ }
+
+ t = t.getSuperclass();
+ }
+ while (t != null);
+
+ throw new NoSuchMethodException("No similar method " + name + " with params " + Arrays.toString(types) + " could be found on type " + type() + ".");
+ }
+
+ /**
+ * Determines if a method has a "similar" signature, especially if wrapping
+ * primitive argument types would result in an exactly matching signature.
+ */
+ private boolean isSimilarSignature(Method possiblyMatchingMethod, String desiredMethodName, Class>[] desiredParamTypes) {
+ return possiblyMatchingMethod.getName().equals(desiredMethodName) && match(possiblyMatchingMethod.getParameterTypes(), desiredParamTypes);
+ }
+
+ /**
+ * Call a constructor.
+ *
+ * This is a convenience method for calling
+ *
+ * This is roughly equivalent to {@link Constructor#newInstance(Object...)}.
+ * If the wrapped object is a {@link Class}, then this will create a new
+ * object of that class. If the wrapped object is any other {@link Object},
+ * then this will create a new object of the same type.
+ *
+ * Just like {@link Constructor#newInstance(Object...)}, this will try to
+ * wrap primitive types or unwrap primitive type wrappers if applicable. If
+ * several constructors are applicable, by that rule, the first one
+ * encountered is called. i.e. when calling P as(final Class proxyType) {
+ final boolean isMap = (object instanceof Map);
+ final InvocationHandler handler = new InvocationHandler() {
+ @SuppressWarnings("null")
+ @Override
+ public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
+ String name = method.getName();
+
+ // Actual method name matches always come first
+ try {
+ return on(type, object).call(name, args).get();
+ }
+
+ // [#14] Emulate POJO behaviour on wrapped map objects
+ catch (ReflectException e) {
+ if (isMap) {
+ Map
+ * These exceptions are
+ * Reflect
is
+ * // Static import all reflection methods to decrease verbosity
+ * import static org.joor.Reflect.*;
+ *
+ * // Wrap an Object / Class / class name with the on() method:
+ * on("java.lang.String")
+ * // Invoke constructors using the create() method:
+ * .create("Hello World")
+ * // Invoke methods using the call() method:
+ * .call("toString")
+ * // Retrieve the wrapped object
+ *
+ * @author Lukas Eder
+ * @author Irek Matysiewicz
+ * @author Thomas Darimont
+ */
+public class Reflect {
+
+ // ---------------------------------------------------------------------
+ // Static API used as entrance points to the fluent API
+ // ---------------------------------------------------------------------
+
+ /**
+ * Wrap a class name.
+ *
on(Class.forName(name))
+ *
+ * @param name A fully qualified class name
+ * @return A wrapped class object, to be used for further reflection.
+ * @throws ReflectException If any reflection exception occurred.
+ * @see #on(Class)
+ */
+ public static Reflect on(String name) throws ReflectException {
+ return on(forName(name));
+ }
+
+ /**
+ * Wrap a class name, loading it via a given class loader.
+ * on(Class.forName(name, classLoader))
+ *
+ * @param name A fully qualified class name.
+ * @param classLoader The class loader in whose context the class should be
+ * loaded.
+ * @return A wrapped class object, to be used for further reflection.
+ * @throws ReflectException If any reflection exception occurred.
+ * @see #on(Class)
+ */
+ public static Reflect on(String name, ClassLoader classLoader) throws ReflectException {
+ return on(forName(name, classLoader));
+ }
+
+ /**
+ * Wrap a class.
+ *
+ *
+ * @return A map containing field names and wrapped values.
+ */
+ public Map
+ * on(object).field("myField");
+ * on(object).fields().get("myField");
+ *
call(name, new Object[0])
+ *
+ * @param name The method name
+ * @return The wrapped method result or the same wrapped object if the
+ * method returns void
, to be used for further
+ * reflection.
+ * @throws ReflectException If any reflection exception occurred.
+ * @see #call(String, Object...)
+ */
+ public Reflect call(String name) throws ReflectException {
+ return call(name, new Object[0]);
+ }
+
+ /**
+ * Call a method by its name.
+ *
The first of the following methods will be called:
+ *
+ * on(...).call("method", 1, 1);
+ *
+ *
+ * public void method(int param1, Integer param2);
+ * public void method(Integer param1, int param2);
+ * public void method(Number param1, Number param2);
+ * public void method(Number param1, Object param2);
+ * public void method(int param1, Object param2);
+ *
+ *
+ *
+ * @param name The method name
+ * @param args The method arguments
+ * @return The wrapped method result or the same wrapped object if the
+ * method returns void
, to be used for further
+ * reflection.
+ * @throws ReflectException If any reflection exception occurred.
+ */
+ public Reflect call(String name, Object... args) throws ReflectException {
+ Class>[] types = types(args);
+
+ // Try invoking the "canonical" method, i.e. the one with exact
+ // matching argument types
+ try {
+ Method method = exactMethod(name, types);
+ return on(method, object, args);
+ }
+
+ // If there is no exact match, try to find a method that has a "similar"
+ // signature if primitive argument types are converted to their wrappers
+ catch (NoSuchMethodException e) {
+ try {
+ Method method = similarMethod(name, types);
+ return on(method, object, args);
+ } catch (NoSuchMethodException e1) {
+ throw new ReflectException(e1);
+ }
+ }
+ }
+
+ /**
+ * Searches a method with the exact same signature as desired.
+ * create(new Object[0])
+ *
+ * @return The wrapped new object, to be used for further reflection.
+ * @throws ReflectException If any reflection exception occurred.
+ * @see #create(Object...)
+ */
+ public Reflect create() throws ReflectException {
+ return create(new Object[0]);
+ }
+
+ /**
+ * Call a constructor.
+ *
The first of the following constructors will be applied:
+ *
+ * on(C.class).create(1, 1);
+ *
+ *
+ * @param args The constructor arguments
+ * @return The wrapped new object, to be used for further reflection.
+ * @throws ReflectException If any reflection exception occurred.
+ */
+ public Reflect create(Object... args) throws ReflectException {
+ Class>[] types = types(args);
+
+ // Try invoking the "canonical" constructor, i.e. the one with exact
+ // matching argument types
+ try {
+ Constructor> constructor = type().getDeclaredConstructor(types);
+ return on(constructor, args);
+ }
+
+ // If there is no exact match, try to find one that has a "similar"
+ // signature if primitive argument types are converted to their wrappers
+ catch (NoSuchMethodException e) {
+ for (Constructor> constructor : type().getDeclaredConstructors()) {
+ if (match(constructor.getParameterTypes(), types)) {
+ return on(constructor, args);
+ }
+ }
+
+ throw new ReflectException(e);
+ }
+ }
+
+ /**
+ * Create a proxy for the wrapped object allowing to typesafely invoke
+ * methods on it using a custom interface
+ *
+ * @param proxyType The interface type that is implemented by the proxy
+ * @return A proxy for the wrapped object
+ */
+ @SuppressWarnings("unchecked")
+ public
+ * public C(int param1, Integer param2);
+ * public C(Integer param1, int param2);
+ * public C(Number param1, Number param2);
+ * public C(Number param1, Object param2);
+ * public C(int param1, Object param2);
+ *
+ *
+ *
+ * @author Lukas Eder
+ */
+public class ReflectException extends RuntimeException {
+
+ /**
+ * Generated UID
+ */
+ private static final long serialVersionUID = -6213149635297151442L;
+
+ public ReflectException(String message) {
+ super(message);
+ }
+
+ public ReflectException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public ReflectException() {
+ super();
+ }
+
+ public ReflectException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/detox/scripts/build.sh b/detox/scripts/build.sh
index a39e690198..10208af16b 100755
--- a/detox/scripts/build.sh
+++ b/detox/scripts/build.sh
@@ -9,3 +9,15 @@ echo -e "\nBuilding Detox.framework"
rm -fr DetoxBuild
tar -cjf Detox.framework.tbz Detox.framework
fi
+
+if [ "$1" == "android" -o "$2" == "android" ] ; then
+ echo -e "\nBuilding Detox aars"
+ rm -fr detox-debug.aar
+ rm -fr detox-release.aar
+ cd android
+ ./gradlew assembleDebug
+ ./gradlew assembleRelease
+ cd ..
+ cp -fr android/detox/build/outputs/aar/detox-debug.aar .
+ cp -fr android/detox/build/outputs/aar/detox-release.aar .
+fi
diff --git a/detox/test/android/app/build.gradle b/detox/test/android/app/build.gradle
index 156630953f..3ef529c745 100644
--- a/detox/test/android/app/build.gradle
+++ b/detox/test/android/app/build.gradle
@@ -89,6 +89,11 @@ android {
ndk {
abiFilters "armeabi-v7a", "x86"
}
+ testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ testInstrumentationRunnerArguments = [
+ 'detoxServer': 'ws://10.0.2.2:8099',
+ 'detoxSessionId': 'test'
+ ]
}
splits {
abi {
@@ -121,8 +126,15 @@ android {
dependencies {
compile fileTree(dir: "libs", include: ["*.jar"])
+ compile project(":detox")
compile "com.android.support:appcompat-v7:23.0.1"
compile "com.facebook.react:react-native:+" // From node_modules
+
+ testCompile 'junit:junit:4.12'
+ androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', {
+ exclude group: 'com.android.support', module: 'support-annotations'
+ exclude group: 'com.google.code.findbugs'
+ })
}
// Run this once to be able to run the application with BUCK
diff --git a/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java b/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
new file mode 100644
index 0000000000..7976be6340
--- /dev/null
+++ b/detox/test/android/app/src/androidTest/java/com/example/DetoxTest.java
@@ -0,0 +1,34 @@
+package com.example;
+
+import android.support.test.filters.LargeTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.wix.detox.Detox;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Created by simonracz on 28/05/2017.
+ */
+
+@RunWith(AndroidJUnit4.class)
+@LargeTest
+public class DetoxTest {
+
+ @Rule
+ public ActivityTestRule