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 looperIdlingResources = new ArrayList<>(); + + private static void setupReactNativeQueueInterrogators(@NonNull Object reactContext) { + HashSet excludedLoopers = new HashSet<>(); + excludedLoopers.add(InstrumentationRegistry.getTargetContext().getMainLooper()); + setupRNQueueInterrogator(reactContext, FIELD_UI_MSG_QUEUE, excludedLoopers); + setupRNQueueInterrogator(reactContext, FIELD_UI_BG_MSG_QUEUE, excludedLoopers); + setupRNQueueInterrogator(reactContext, FIELD_JS_MSG_QUEUE, excludedLoopers); + setupRNQueueInterrogator(reactContext, FIELD_NATIVE_MODULES_MSG_QUEUE, excludedLoopers); + } + + private static void setupRNQueueInterrogator( + @NonNull Object reactContext, + @NonNull String field, + @NonNull HashSet excludedLoopers) { + Object queue; + Object looper; + + try { + if ((queue = Reflect.on(reactContext).field(field).get()) != null) { + if ((looper = Reflect.on(queue).call(METHOD_GET_LOOPER).get()) != null) { + if (!excludedLoopers.contains(looper)) { + IdlingResource looperIdlingResource = + Reflect.on(CLASS_ESPRESSO_LOOPER_IDLING_RESOURCE).create(looper, false).get(); + + looperIdlingResources.add(looperIdlingResource); + Espresso.registerIdlingResources(looperIdlingResource); + excludedLoopers.add((Looper)looper); + } + } + } + } catch (ReflectException e) { + // The mUiBackgroundMessageQueueThread field is stripped at runtime + // in the current RN release. + // We should still keep trying to grab it to be future proof. + if (!field.equals("mUiBackgroundMessageQueueThread")) { + Log.d(LOG_TAG, "Can't set up monitoring for " + field, e); + } + } + } + + static void removeEspressoIdlingResources(@NonNull Object reactNativeHostHolder) { + Object reactContext = null; + final Object instanceManager = getInstanceManager(reactNativeHostHolder); + if (instanceManager != null) { + reactContext = Reflect.on(instanceManager).call(METHOD_GET_REACT_CONTEXT).get(); + } + + removeEspressoIdlingResources(reactNativeHostHolder, reactContext); + } + + private static void removeEspressoIdlingResources( + @NonNull Object reactNativeHostHolder, + Object reactContext) { + + Log.i(LOG_TAG, "Removing Espresso IdlingResources for React Native."); + + if (rnBridgeIdlingResource != null && rnTimerIdlingResource != null) { + Espresso.unregisterIdlingResources(rnTimerIdlingResource, rnBridgeIdlingResource); + rnTimerIdlingResource = null; + rnBridgeIdlingResource = null; + } + + removeReactNativeQueueInterrogators(); + + final Object instanceManager = getInstanceManager(reactNativeHostHolder); + if (instanceManager == null) { + return; + } + + if (bridgeIdleSignaler != null) { + if (reactContext != null) { + Reflect.on(reactContext) + .call(METHOD_GET_CATALYST_INSTANCE) + .call(METHOD_REMOVE_DEBUG_BRIDGE_LISTENER, bridgeIdleSignaler); + } + bridgeIdleSignaler = null; + } + } + + private static void removeReactNativeQueueInterrogators() { + for (IdlingResource res : looperIdlingResources) { + Espresso.unregisterIdlingResources(res); + } + looperIdlingResources.clear(); + } +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/TestRunner.java b/detox/android/detox/src/main/java/com/wix/detox/TestRunner.java index 18d5da729b..f8a51d7a54 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/TestRunner.java +++ b/detox/android/detox/src/main/java/com/wix/detox/TestRunner.java @@ -1,6 +1,6 @@ package com.wix.detox; -import com.wix.invoke.MethodInvocation; +//import com.wix.invoke.MethodInvocation; /** * Created by rotemm on 04/01/2017. @@ -13,7 +13,7 @@ public TestRunner() { } public void invoke(String jsonData) { - Object val = MethodInvocation.invoke(jsonData); + //Object val = MethodInvocation.invoke(jsonData); } diff --git a/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java b/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java index f8df8f5844..692275f03c 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java +++ b/detox/android/detox/src/main/java/com/wix/detox/WebSocketClient.java @@ -7,70 +7,151 @@ import org.json.JSONException; import org.json.JSONObject; +import java.io.BufferedReader; +import java.io.IOException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; import okhttp3.OkHttpClient; import okhttp3.Request; +import okhttp3.RequestBody; import okhttp3.Response; -import okhttp3.WebSocket; -import okhttp3.WebSocketListener; -import okio.ByteString; +import okhttp3.ResponseBody; +import okhttp3.ws.WebSocket; +import okhttp3.ws.WebSocketCall; +import okhttp3.ws.WebSocketListener; +import okio.Buffer; + +import static okhttp3.ws.WebSocket.TEXT; /** * Created by rotemm on 27/12/2016. */ -public class WebSocketClient extends WebSocketListener { +public class WebSocketClient implements WebSocketListener { + + @Override + public void onOpen(WebSocket webSocket, Response response) { + Log.i(LOG_TAG, "At onOpen"); + this.websocket = webSocket; + HashMap params = new HashMap(); + params.put("sessionId", sessionId); + params.put("role", "testee"); + sendAction("login", params); + actionHandler.onConnect(); + } + + @Override + public void onFailure(IOException e, Response response) { + Log.e(LOG_TAG, "Detox Error: ", e); + + //OKHttp won't recover from failure if it got ConnectException, + // this is a workaround to make the websocket client try reconnecting when failed. + try { + Thread.sleep(1000); + } catch (InterruptedException e2) { + Log.d(LOG_TAG, "interrupted", e2); + } + Log.d(LOG_TAG, "Retrying..."); + connectToServer(url, sessionId); + } + + @Override + public void onMessage(ResponseBody message) throws IOException { + Log.i(LOG_TAG, "At onMessage"); + if (message.contentType() == WebSocket.TEXT) { + StringBuffer sb = new StringBuffer(); + String line; + BufferedReader buffer = new BufferedReader(message.charStream()); + while ((line = buffer.readLine()) != null) { + sb.append(line); + } + receiveAction(websocket, sb.toString()); + } + message.close(); + } + + @Override + public void onPong(Buffer payload) { + // empty + } + + @Override + public void onClose(int code, String reason) { + Log.i(LOG_TAG, "At onClose"); + try { + websocket.close(NORMAL_CLOSURE_STATUS, null); + } catch (IOException e) { + Log.e(LOG_TAG, "WS close", e); + } + Log.d(LOG_TAG, "Detox Closed: " + code + " " + reason); + actionHandler.onClosed(); + } private static final String LOG_TAG = "WebSocketClient"; private String url; private String sessionId; private OkHttpClient client; - private WebSocket websocket; + private WebSocket websocket = null; private ActionHandler actionHandler; + private static final int NORMAL_CLOSURE_STATUS = 1000; + + // TODO + // Need an API to stop the websocket from DetoxManager + public WebSocketClient(ActionHandler actionHandler) { this.actionHandler = actionHandler; } public void connectToServer(String sessionId) { + connectToServer(Environment.getServerHost(), sessionId); } public void connectToServer(String url, String sessionId) { + Log.i(LOG_TAG, "At connectToServer"); this.url = url; this.sessionId = sessionId; client = new OkHttpClient.Builder(). - retryOnConnectionFailure(true). - connectTimeout(1500, TimeUnit.MILLISECONDS). - readTimeout(0, TimeUnit.MILLISECONDS).build(); + retryOnConnectionFailure(true). + connectTimeout(1500, TimeUnit.MILLISECONDS). + readTimeout(0, TimeUnit.MILLISECONDS).build(); Request request = new Request.Builder().url(url).build(); - websocket = client.newWebSocket(request, this); + + WebSocketCall.create(client, request).enqueue(this); + client.dispatcher().executorService().shutdown(); } public void sendAction(String type, Map params) { + Log.i(LOG_TAG, "At sendAction"); HashMap data = new HashMap(); data.put("type", type); data.put("params", params); JSONObject json = new JSONObject(data); - websocket.send(json.toString()); + try { + websocket.sendMessage(RequestBody.create(TEXT, json.toString())); + } catch (IOException e) { + Log.e(LOG_TAG, "Error sending msg through WS", e); + } + Log.d(LOG_TAG, "Detox Action Sent: " + type); - if(actionHandler != null) actionHandler.onAction(type, params); + } public void receiveAction(WebSocket webSocket, String json) { + Log.i(LOG_TAG, "At receiveAction"); try { JSONObject object = new JSONObject(json); String type = (String) object.get("type"); - if(type == null) { + if (type == null) { Log.e(LOG_TAG, "Detox Error: receiveAction missing type"); return; } @@ -81,57 +162,22 @@ public void receiveAction(WebSocket webSocket, String json) { } Log.d(LOG_TAG, "Detox Action Received: " + type); + // TODO + // This is just a dummy call now. Finish parsing params. + if (actionHandler != null) actionHandler.onAction(type, new HashMap()); } catch (JSONException e) { Log.e(LOG_TAG, "Detox Error: receiveAction decode - " + e.toString()); } } - @Override - public void onOpen(WebSocket webSocket, Response response) { - HashMap params = new HashMap(); - params.put("sessionId", sessionId); - params.put("role", "testee"); - sendAction("login", params); - actionHandler.onConnect(); - } - - @Override - public void onMessage(WebSocket webSocket, String text) { - receiveAction(webSocket, text); - } - - @Override - public void onMessage(WebSocket webSocket, ByteString bytes) { - - } - - @Override - public void onClosing(WebSocket webSocket, int code, String reason) { - webSocket.close(1000, null); - Log.d(LOG_TAG, "Detox Closed: " + code + " " + reason); - actionHandler.onClosed(); - } - - @Override - public void onFailure(WebSocket webSocket, Throwable t, Response response) { - Log.e(LOG_TAG, "Detox Error: ", t); - - //OKHttp won't recover from failure if it got ConnectException, - // this is a workaround to make the websocket client try reconnecting when failed. - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - e.printStackTrace(); - } - Log.d(LOG_TAG, "Retrying..."); - connectToServer(sessionId); - - } - + /** + * These methods are called on an inner worker thread. + * @see OkHTTP + */ public interface ActionHandler { void onAction(String type, Map params); void onConnect(); void onClosed(); } -} \ No newline at end of file +} diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactBridgeIdlingResource.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactBridgeIdlingResource.java new file mode 100644 index 0000000000..6f7f44ba00 --- /dev/null +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactBridgeIdlingResource.java @@ -0,0 +1,55 @@ +package com.wix.detox.espresso; + +import android.support.test.espresso.IdlingResource; +import android.util.Log; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Created by simonracz on 01/06/2017. + */ + +/** + *

+ * 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 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.
+     * 

+ * This is the same as calling 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. + *

+ * This is the same as calling + * 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. + *

+ * 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 T accessible(T accessible) { + if (accessible == null) { + return null; + } + + if (accessible instanceof Member) { + Member member = (Member) accessible; + + if (Modifier.isPublic(member.getModifiers()) && + Modifier.isPublic(member.getDeclaringClass().getModifiers())) { + + return accessible; + } + } + + // [jOOQ #3392] The accessible flag is set to false by default, also for public members. + if (!accessible.isAccessible()) { + accessible.setAccessible(true); + } + + return accessible; + } + + // --------------------------------------------------------------------- + // Members + // --------------------------------------------------------------------- + + + + + + + + + + + + + + + + + + /** + * The type of the wrapped object. + */ + private final Class type; + + /** + * The wrapped object. + */ + private final Object object; + + // --------------------------------------------------------------------- + // Constructors + // --------------------------------------------------------------------- + + private Reflect(Class type) { + this(type, type); + } + + private Reflect(Class type, Object object) { + this.type = type; + this.object = object; + } + + // --------------------------------------------------------------------- + // Fluent Reflection API + // --------------------------------------------------------------------- + + /** + * Get the wrapped object + * + * @param A convenience generic parameter for automatic unsafe casting + */ + @SuppressWarnings("unchecked") + public T get() { + return (T) object; + } + + /** + * Set a field value. + *

+ * 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 T get(String name) throws ReflectException { + return field(name).get(); + } + + /** + * Get a wrapped field. + *

+ * 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

+     * on(object).field("myField");
+     * on(object).fields().get("myField");
+     * 
+ * + * @return A map containing field names and wrapped values. + */ + public Map fields() { + Map result = new LinkedHashMap(); + Class t = type(); + + do { + for (Field field : t.getDeclaredFields()) { + if (type != object ^ Modifier.isStatic(field.getModifiers())) { + String name = field.getName(); + + if (!result.containsKey(name)) + result.put(name, field(name)); + } + } + + t = t.getSuperclass(); + } + while (t != null); + + return result; + } + + /** + * Call a method by its name. + *

+ * This is a convenience method for calling + * 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. + *

+ * 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

+     * on(...).call("method", 1, 1);
+     * 
The first of the following methods will be called: + *
+     * 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);
+     * 
+ *

+ * The best matching method is searched for with the following strategy: + *

    + *
  1. public method with exact signature match in class hierarchy
  2. + *
  3. non-public method with exact signature match on declaring class
  4. + *
  5. public method with similar signature in class hierarchy
  6. + *
  7. non-public method with similar signature on declaring class
  8. + *
+ * + * @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. + *

+ * 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 + * 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. + *

+ * 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

+     * on(C.class).create(1, 1);
+     * 
The first of the following constructors will be applied: + *
+     * 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);
+     * 
+ * + * @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

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 map = (Map) object; + int length = (args == null ? 0 : args.length); + + if (length == 0 && name.startsWith("get")) { + return map.get(property(name.substring(3))); + } + else if (length == 0 && name.startsWith("is")) { + return map.get(property(name.substring(2))); + } + else if (length == 1 && name.startsWith("set")) { + map.put(property(name.substring(3)), args[0]); + return null; + } + } + + + + + + + + + + + + throw e; + } + } + }; + + return (P) Proxy.newProxyInstance(proxyType.getClassLoader(), new Class[] { proxyType }, handler); + } + + /** + * Get the POJO property name of an getter/setter + */ + private static String property(String string) { + int length = string.length(); + + if (length == 0) { + return ""; + } + else if (length == 1) { + return string.toLowerCase(); + } + else { + return string.substring(0, 1).toLowerCase() + string.substring(1); + } + } + + // --------------------------------------------------------------------- + // Object API + // --------------------------------------------------------------------- + + /** + * Check whether two arrays of types match, converting primitive types to + * their corresponding wrappers. + */ + private boolean match(Class[] declaredTypes, Class[] actualTypes) { + if (declaredTypes.length == actualTypes.length) { + for (int i = 0; i < actualTypes.length; i++) { + if (actualTypes[i] == NULL.class) + continue; + + if (wrapper(declaredTypes[i]).isAssignableFrom(wrapper(actualTypes[i]))) + continue; + + return false; + } + + return true; + } + else { + return false; + } + } + + /** + * {@inheritDoc} + */ + @Override + public int hashCode() { + return object.hashCode(); + } + + /** + * {@inheritDoc} + */ + @Override + public boolean equals(Object obj) { + if (obj instanceof Reflect) { + return object.equals(((Reflect) obj).get()); + } + + return false; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return object.toString(); + } + + // --------------------------------------------------------------------- + // Utility methods + // --------------------------------------------------------------------- + + /** + * Wrap an object created from a constructor + */ + private static Reflect on(Constructor constructor, Object... args) throws ReflectException { + try { + return on(constructor.getDeclaringClass(), accessible(constructor).newInstance(args)); + } + catch (Exception e) { + throw new ReflectException(e); + } + } + + /** + * Wrap an object returned from a method + */ + private static Reflect on(Method method, Object object, Object... args) throws ReflectException { + try { + accessible(method); + + if (method.getReturnType() == void.class) { + method.invoke(object, args); + return on(object); + } + else { + return on(method.invoke(object, args)); + } + } + catch (Exception e) { + throw new ReflectException(e); + } + } + + /** + * Unwrap an object + */ + private static Object unwrap(Object object) { + if (object instanceof Reflect) { + return ((Reflect) object).get(); + } + + return object; + } + + /** + * Get an array of types for an array of objects + * + * @see Object#getClass() + */ + private static Class[] types(Object... values) { + if (values == null) { + return new Class[0]; + } + + Class[] result = new Class[values.length]; + + for (int i = 0; i < values.length; i++) { + Object value = values[i]; + result[i] = value == null ? NULL.class : value.getClass(); + } + + return result; + } + + /** + * Load a class + * + * @see Class#forName(String) + */ + private static Class forName(String name) throws ReflectException { + try { + return Class.forName(name); + } + catch (Exception e) { + throw new ReflectException(e); + } + } + + private static Class forName(String name, ClassLoader classLoader) throws ReflectException { + try { + return Class.forName(name, true, classLoader); + } + catch (Exception e) { + throw new ReflectException(e); + } + } + + /** + * Get the type of the wrapped object. + * + * @see Object#getClass() + */ + public Class type() { + return type; + } + + /** + * Get a wrapper type for a primitive type, or the argument type itself, if + * it is not a primitive type. + */ + public static Class wrapper(Class type) { + if (type == null) { + return null; + } + else if (type.isPrimitive()) { + if (boolean.class == type) { + return Boolean.class; + } + else if (int.class == type) { + return Integer.class; + } + else if (long.class == type) { + return Long.class; + } + else if (short.class == type) { + return Short.class; + } + else if (byte.class == type) { + return Byte.class; + } + else if (double.class == type) { + return Double.class; + } + else if (float.class == type) { + return Float.class; + } + else if (char.class == type) { + return Character.class; + } + else if (void.class == type) { + return Void.class; + } + } + + return type; + } + + private static class NULL {} +} diff --git a/detox/android/detox/src/main/java/org/joor/ReflectException.java b/detox/android/detox/src/main/java/org/joor/ReflectException.java new file mode 100644 index 0000000000..0fe33435a1 --- /dev/null +++ b/detox/android/detox/src/main/java/org/joor/ReflectException.java @@ -0,0 +1,57 @@ +/* + * 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.InvocationTargetException; + +/** + * A unchecked wrapper for any of Java's checked reflection exceptions: + *

+ * These exceptions are + *

    + *
  • {@link ClassNotFoundException}
  • + *
  • {@link IllegalAccessException}
  • + *
  • {@link IllegalArgumentException}
  • + *
  • {@link InstantiationException}
  • + *
  • {@link InvocationTargetException}
  • + *
  • {@link NoSuchMethodException}
  • + *
  • {@link NoSuchFieldException}
  • + *
  • {@link SecurityException}
  • + *
+ * + * @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 mActivityRule = new ActivityTestRule(MainActivity.class); + + @Before + public void setUpCustomEspressoIdlingResources() { + // empty + } + + @Test + public void runDetoxTests() { + Detox.runTests(); + } +} diff --git a/detox/test/android/app/src/main/AndroidManifest.xml b/detox/test/android/app/src/main/AndroidManifest.xml index 029976545e..8275835d55 100644 --- a/detox/test/android/app/src/main/AndroidManifest.xml +++ b/detox/test/android/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ android:targetSdkVersion="22" /> getPackages() { - return Arrays.asList( - new MainReactPackage() - ); - } } diff --git a/detox/test/android/app/src/main/java/com/example/MainApplication.java b/detox/test/android/app/src/main/java/com/example/MainApplication.java new file mode 100644 index 0000000000..f3b20cfc40 --- /dev/null +++ b/detox/test/android/app/src/main/java/com/example/MainApplication.java @@ -0,0 +1,44 @@ +package com.example; + +import android.app.Application; + +import com.facebook.react.ReactApplication; +import com.facebook.react.ReactNativeHost; +import com.facebook.react.ReactPackage; +import com.facebook.react.shell.MainReactPackage; +import com.facebook.soloader.SoLoader; + +import java.util.Arrays; +import java.util.List; + +/** + * Created by simonracz on 11/06/2017. + */ + +public class MainApplication extends Application implements ReactApplication { + private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) { + @Override + public boolean getUseDeveloperSupport() { + return BuildConfig.DEBUG; + } + + @Override + protected List getPackages() { + return Arrays.asList( + new MainReactPackage() + ); + } + }; + + @Override + public ReactNativeHost getReactNativeHost() { + return mReactNativeHost; + } + + @Override + public void onCreate() { + super.onCreate(); + SoLoader.init(this, /* native exopackage */ false); + } + +} diff --git a/detox/test/android/detox/build.gradle b/detox/test/android/detox/build.gradle new file mode 100644 index 0000000000..702ad946e7 --- /dev/null +++ b/detox/test/android/detox/build.gradle @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file('../../../detox-debug.aar')) diff --git a/detox/test/android/settings.gradle b/detox/test/android/settings.gradle index 13df8b58c9..293a3d6fad 100644 --- a/detox/test/android/settings.gradle +++ b/detox/test/android/settings.gradle @@ -1,3 +1,3 @@ rootProject.name = 'example' -include ':app' +include ':app', ':detox' diff --git a/detox/test/index.android.js b/detox/test/index.android.js index f843700d16..b174806e71 100644 --- a/detox/test/index.android.js +++ b/detox/test/index.android.js @@ -1,52 +1,49 @@ -/** - * Sample React Native App - * https://github.com/facebook/react-native - * @flow - */ - import React, { Component } from 'react'; import { AppRegistry, StyleSheet, Text, - View + View, + TouchableOpacity } from 'react-native'; -class example extends Component { +export default class example extends Component { + constructor(props) { + super(props); + this.state = { + greeting: undefined + }; + } render() { + if (this.state.greeting) return this.renderAfterButton(); return ( - - - Welcome to React Native! - - - To get started, edit index.android.js + + + Welcome - - Shake or press menu button for dev menu + + Say Hello + + + Say World + + + ); + } + renderAfterButton() { + return ( + + + {this.state.greeting}!!! ); } + onButtonPress(greeting) { + this.setState({ + greeting: greeting + }); + } } -const styles = StyleSheet.create({ - container: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#F5FCFF', - }, - welcome: { - fontSize: 20, - textAlign: 'center', - margin: 10, - }, - instructions: { - textAlign: 'center', - color: '#333333', - marginBottom: 5, - }, -}); - -AppRegistry.registerComponent('example', () => example); +AppRegistry.registerComponent('example', () => example); \ No newline at end of file diff --git a/detox/test/package.json b/detox/test/package.json index e07ddde938..5c345605c9 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -14,9 +14,11 @@ }, "devDependencies": { "detox": "^5.0.0", - "mocha": "^3.2.0", + "express": "^4.15.3", "lodash": "^4.14.1", - "express": "^4.15.3" + "mocha": "^3.2.0", + "wsrelay": "^1.0.0", + "wscat": "^1.0.0" }, "detox": { "specs": "e2e", diff --git a/detox/test/scripts/test_android.sh b/detox/test/scripts/test_android.sh new file mode 100755 index 0000000000..d5c3b59508 --- /dev/null +++ b/detox/test/scripts/test_android.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# This is a helper script for testing *manually* detox Android +# Prerequisites: +# 0. detox has detox-debug.aar (created by e.g "scripts/build.sh noframework android") +# 1. RN packager is running in detox/test/ +# 2. An Android simulator is running + +echo -e "\nStarting ws echo server" +node_modules/wsrelay/cmd.js 8099 &>/dev/null & +PROC_ID_WS=$! + +echo -e "\nInstalling app apk and test apk" +cd android +./gradlew installDebug +./gradlew installDebugAndroidTest + +echo -e "\nStarting instrumentation test" +./gradlew connectedAndroidTest &>/dev/null & +PROC_ID_INST=$! +cd .. + +echo -e "\nStart sending test commands" +echo -e "\nAvailable test commands\n{'type':'testInvoke1','params':{}}\n{'type':'testPush','params':{}}\n{'type':'testInvoke2','params':{}}\n{'type':'testInvokeNeg1','params':{}}\n" +echo -e "{'type':'reactNativeReload','params':{}}\n{'type':'cleanup','params':{}}\n{'type':'isReady','params':{}}" +node_modules/wscat/bin/wscat --connect ws://localhost:8099 + +echo -e "\nStopping ws echo server" +kill -5 "$PROC_ID_WS" + +echo -e "\nStopping instrumentation test runner (if needed)" +kill -5 "$PROC_ID_INST" \ No newline at end of file