diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index 1e7358b86a..01bcc2f7b9 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -1,11 +1,14 @@
apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-android-extensions'
-def _ext = rootProject.ext;
+def _ext = rootProject.ext
-def _compileSdkVersion = _ext.has('compileSdkVersion') ? _ext.compileSdkVersion : 25;
-def _buildToolsVersion = _ext.has('buildToolsVersion') ? _ext.buildToolsVersion : '27.0.3';
-def _minSdkVersion = _ext.has('minSdkVersion') ? _ext.minSdkVersion : 18;
-def _targetSdkVersion = _ext.has('targetSdkVersion') ? _ext.targetSdkVersion : 25;
+def _compileSdkVersion = _ext.has('compileSdkVersion') ? _ext.compileSdkVersion : 25
+def _buildToolsVersion = _ext.has('buildToolsVersion') ? _ext.buildToolsVersion : '27.0.3'
+def _minSdkVersion = _ext.has('minSdkVersion') ? _ext.minSdkVersion : 18
+def _targetSdkVersion = _ext.has('targetSdkVersion') ? _ext.targetSdkVersion : 25
+def _kotlinVersion = _ext.has('detoxKotlinVersion') ? _ext.detoxKotlinVersion : '1.3.0'
android {
compileSdkVersion _compileSdkVersion
@@ -64,6 +67,8 @@ android {
}
dependencies {
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$_kotlinVersion"
+
minReactNative44Implementation 'com.squareup.okhttp3:okhttp:3.4.1'
minReactNative44Implementation 'com.squareup.okhttp3:okhttp-ws:3.4.1'
@@ -84,4 +89,6 @@ dependencies {
testImplementation 'junit:junit:4.12'
testImplementation 'org.assertj:assertj-core:3.8.0'
testImplementation 'org.apache.commons:commons-io:1.3.2'
+ testImplementation 'com.nhaarman:mockito-kotlin:1.4.0'
+ testImplementation "com.facebook.react:react-native:+"
}
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
deleted file mode 100644
index 013c99b469..0000000000
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactNativeTimersIdlingResource.java
+++ /dev/null
@@ -1,198 +0,0 @@
-package com.wix.detox.espresso;
-
-import android.support.annotation.NonNull;
-import android.support.test.espresso.IdlingResource;
-import android.util.Log;
-import android.view.Choreographer;
-
-import org.joor.Reflect;
-import org.joor.ReflectException;
-
-import java.util.PriorityQueue;
-import java.util.concurrent.atomic.AtomicBoolean;
-
-/**
- * Created by simonracz on 23/05/2017.
- */
-
-/**
- *
- * Espresso IdlingResource for React Native js timers.
- *
- *
- *
- * Hooks up to React Native internals to grab the timers queue 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, Choreographer.FrameCallback {
- private static final String LOG_TAG = "Detox";
-
- private final static String CLASS_TIMING = "com.facebook.react.modules.core.Timing";
- 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 TIMER_FIELD_TARGET_TIME = "mTargetTime";
- private final static String TIMER_FIELD_INTERVAL = "mInterval";
- private final static String TIMER_FIELD_REPETITIVE = "mRepeat";
- private final static String FIELD_CATALYST_INSTANCE = "mCatalystInstance";
- private final static String LOCK_TIMER = "mTimerGuard";
-
- private AtomicBoolean paused = new AtomicBoolean(false);
-
- private static final long LOOK_AHEAD_MS = 1500;
-
- private ResourceCallback callback = null;
- private Object reactContext = null;
-
- public ReactNativeTimersIdlingResource(@NonNull Object reactContext) {
- this.reactContext = reactContext;
- }
-
- @Override
- public String getName() {
- return ReactNativeTimersIdlingResource.class.getName();
- }
-
- @Override
- public boolean isIdleNow() {
- if (paused.get()) {
- return true;
- }
-
- Class> timingClass;
- try {
- timingClass = Class.forName(CLASS_TIMING);
- } 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 (Reflect.on(reactContext).field(FIELD_CATALYST_INSTANCE).get() == null) {
- 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;
- }
-
- final Object timingModule = Reflect.on(reactContext).call(METHOD_GET_NATIVE_MODULE, timingClass).get();
- final Object timerLock = Reflect.on(timingModule).field(LOCK_TIMER).get();
- synchronized (timerLock) {
- final PriorityQueue> timers = Reflect.on(timingModule).field(FIELD_TIMERS).get();
- final Object nextTimer = findNextTimer(timers);
- if (nextTimer == null) {
- if (callback != null) {
- callback.onTransitionToIdle();
- }
- return true;
- }
-
-// Log.i(LOG_TAG, "Num of Timers : " + timers.size());
-
- if (isTimerOutsideBusyWindow(nextTimer)) {
- if (callback != null) {
- callback.onTransitionToIdle();
- }
- return true;
- }
- }
-
- Choreographer.getInstance().postFrameCallback(this);
- Log.i(LOG_TAG, "JS Timer is busy");
- 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;
-
- Choreographer.getInstance().postFrameCallback(this);
- }
-
- @Override
- public void doFrame(long frameTimeNanos) {
- isIdleNow();
- }
-
- public void pause() {
- paused.set(true);
- if (callback != null) {
- callback.onTransitionToIdle();
- }
- }
- public void resume() {
- paused.set(false);
- }
-
- private Object findNextTimer(PriorityQueue> timers) {
- Object nextTimer = timers.peek();
- if (nextTimer == null) {
- return null;
- }
-
- final boolean isRepetitive = Reflect.on(nextTimer).field(TIMER_FIELD_REPETITIVE).get();
- if (!isRepetitive) {
- return nextTimer;
- }
-
- Object timer = null;
- long targetTime = Long.MAX_VALUE;
- for (Object aTimer : timers) {
- final boolean timerIsRepetitive = Reflect.on(aTimer).field(TIMER_FIELD_REPETITIVE).get();
- final long timerTargetTime = Reflect.on(aTimer).field(TIMER_FIELD_TARGET_TIME).get();
- if (!timerIsRepetitive && timerTargetTime < targetTime) {
- targetTime = timerTargetTime;
- timer = aTimer;
- }
- }
- return timer;
- }
-
- private boolean isTimerOutsideBusyWindow(Object nextTimer) {
- final long currentTimeMS = System.nanoTime() / 1000000L;
- final Reflect nextTimerReflected = Reflect.on(nextTimer);
- final long targetTimeMS = nextTimerReflected.field(TIMER_FIELD_TARGET_TIME).get();
- final int intervalMS = nextTimerReflected.field(TIMER_FIELD_INTERVAL).get();
-
-// Log.i(LOG_TAG, "Next timer has duration of: " + intervalMS
-// + "; due time is: " + targetTimeMS + ", current is: " + currentTimeMS);
-
- // Core condition is for the timer interval (duration) to be set beyond our window.
- // Note: we check the interval in an 'absolute' way rather than comparing to the 'current time'
- // since it always takes a while till we get dispatched (compared to when the timer was created),
- // and that could make a significant difference in timers set close to our window (up to ~ LOOK_AHEAD_MS+200ms).
- if (intervalMS > LOOK_AHEAD_MS) {
- return true;
- }
-
- // Edge case: timer has expired during this probing process and is yet to have left the queue.
- if (targetTimeMS <= currentTimeMS) {
- return true;
- }
-
- return false;
- }
-}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactNativeTimersIdlingResource.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactNativeTimersIdlingResource.kt
new file mode 100644
index 0000000000..a5988fc96c
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/ReactNativeTimersIdlingResource.kt
@@ -0,0 +1,115 @@
+package com.wix.detox.espresso
+
+import android.support.test.espresso.IdlingResource
+import android.view.Choreographer
+import com.facebook.react.bridge.ReactContext
+import com.facebook.react.modules.core.Timing
+import org.joor.Reflect
+import java.util.*
+import java.util.concurrent.atomic.AtomicBoolean
+
+const val BUSY_WINDOW_THRESHOLD = 1500
+
+class TimerReflected(timer: Any) {
+ private var reflected = Reflect.on(timer)
+
+ val isRepeating: Boolean
+ get() = reflected.field("mRepeat").get()
+
+ val interval: Int
+ get() = reflected.field("mInterval").get()
+
+ val targetTime: Long
+ get() = reflected.field("mTargetTime").get()
+}
+
+class TimingModuleReflected(reactContext: ReactContext) {
+ private var nativeModule = reactContext.getNativeModule(Timing::class.java)
+
+ val timersQueue: PriorityQueue
+ get() = Reflect.on(nativeModule).field("mTimers").get()
+
+ val timersLock: Object
+ get() = Reflect.on(nativeModule).field("mTimerGuard").get()
+
+ operator fun component1() = timersQueue
+ operator fun component2() = timersLock
+}
+
+class ReactNativeTimersIdlingResource @JvmOverloads constructor(
+ private val reactContext: ReactContext,
+ private val getChoreographer: () -> Choreographer = { Choreographer.getInstance() }
+ ) : IdlingResource, Choreographer.FrameCallback {
+
+ private var callback: IdlingResource.ResourceCallback? = null
+ private var paused = AtomicBoolean(false)
+
+ override fun getName(): String = this.javaClass.name
+
+ override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
+ this.callback = callback
+ getChoreographer().postFrameCallback(this)
+ }
+
+ override fun isIdleNow(): Boolean {
+ if (paused.get()) {
+ return true
+ }
+
+ return checkIdle().apply {
+ val result = this
+ if (result) {
+ callback?.onTransitionToIdle()
+ } else {
+ getChoreographer().postFrameCallback(this@ReactNativeTimersIdlingResource)
+ }
+ }
+ }
+
+ override fun doFrame(frameTimeNanos: Long) {
+ callback?.let {
+ isIdleNow
+ }
+ }
+
+ public fun pause() {
+ paused.set(true)
+ callback?.onTransitionToIdle()
+ }
+
+ public fun resume() {
+ paused.set(false)
+ }
+
+ private fun checkIdle(): Boolean {
+ val now = System.nanoTime() / 1000000L
+ val (timersQueue, timersLock) = TimingModuleReflected(reactContext)
+
+ synchronized(timersLock) {
+ val nextTimer = timersQueue.peek()
+ nextTimer?.let {
+ return !isTimerInBusyWindow(it, now) && !hasBusyTimers(timersQueue, now)
+ }
+ return true
+ }
+ }
+
+ private fun isTimerInBusyWindow(timer: Any, now: Long): Boolean {
+ val timerReflected = TimerReflected(timer)
+ return when {
+ timerReflected.isRepeating -> false
+ timerReflected.targetTime < now -> false
+ timerReflected.interval > BUSY_WINDOW_THRESHOLD -> false
+ else -> true
+ }
+ }
+
+ private fun hasBusyTimers(timersQueue: PriorityQueue, now: Long): Boolean {
+ timersQueue.forEach {
+ if (isTimerInBusyWindow(it, now)) {
+ return true
+ }
+ }
+ return false
+ }
+}
diff --git a/detox/android/detox/src/test/java/com/wix/detox/espresso/ReactNativeTimersIdlingResourceTest.kt b/detox/android/detox/src/test/java/com/wix/detox/espresso/ReactNativeTimersIdlingResourceTest.kt
new file mode 100644
index 0000000000..23f895e561
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/espresso/ReactNativeTimersIdlingResourceTest.kt
@@ -0,0 +1,254 @@
+package com.wix.detox.espresso
+
+import android.support.test.espresso.IdlingResource.ResourceCallback
+import android.view.Choreographer
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.modules.core.Timing
+import com.nhaarman.mockito_kotlin.*
+import org.assertj.core.api.Assertions.assertThat
+import org.joor.Reflect
+import org.junit.Before
+import org.junit.Test
+import org.mockito.ArgumentMatchers
+import java.util.*
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+const val BUSY_INTERVAL_MS = 1500
+const val MEANINGFUL_TIMER_INTERVAL = BUSY_INTERVAL_MS
+
+fun now() = System.nanoTime() / 1000000L
+
+fun aTimer(interval: Int, isRepeating: Boolean) = aTimer(now() + interval + 10, interval, isRepeating)
+
+fun aTimer(targetTime: Long, interval: Int, isRepeating: Boolean): Any {
+ val timerClass = Class.forName("com.facebook.react.modules.core.Timing\$Timer")
+ return Reflect.on(timerClass).create(-1, targetTime, interval, isRepeating).get()
+}
+
+fun aOneShotTimer(interval: Int) = aTimer(interval, false)
+fun aRepeatingTimer(interval: Int) = aTimer(interval, true)
+fun anOverdueTimer() = aTimer(now() - 100, 123, false)
+
+fun aIdlingResourceCallback() = mock()
+
+class ReactNativeTimersIdlingResourceTest {
+
+ private lateinit var reactAppContext: ReactApplicationContext
+ private lateinit var timersLock: String
+ private lateinit var timersNativeModule: Timing
+ private lateinit var choreographer: Choreographer
+ private lateinit var pendingTimers: PriorityQueue
+
+ @Before fun setUp() {
+ pendingTimers = PriorityQueue(2) { _, _ -> 0}
+
+ timersNativeModule = mock()
+ timersLock = "Lock-Mock"
+ Reflect.on(timersNativeModule).set("mTimers", pendingTimers)
+ Reflect.on(timersNativeModule).set("mTimerGuard", timersLock)
+
+ choreographer = mock()
+
+ reactAppContext = mock {
+ on { hasNativeModule(ArgumentMatchers.any()) }.doReturn(true)
+ on { getNativeModule(ArgumentMatchers.any()) }.doReturn(timersNativeModule)
+ }
+ }
+
+ @Test fun `should be idle if there are no timers in queue`() {
+ assertThat(uut().isIdleNow).isTrue()
+ }
+
+ @Test fun `should transition to idle if found idle in query`() {
+ val callback = aIdlingResourceCallback()
+
+ with(uut()) {
+ registerIdleTransitionCallback(callback)
+ isIdleNow
+ }
+
+ verify(callback).onTransitionToIdle()
+ }
+
+ @Test fun `should NOT transition to idle if found busy in query`() {
+ val callback = aIdlingResourceCallback()
+
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+
+ with(uut()) {
+ registerIdleTransitionCallback(callback)
+ isIdleNow
+ }
+
+ verify(callback, never()).onTransitionToIdle()
+ }
+
+ @Test fun `should be busy if there's a meaningful pending timer`() {
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+ assertThat(uut().isIdleNow).isFalse()
+ }
+
+ @Test fun `should be idle if pending timer is too far away (ie not meaningful)`() {
+ givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
+ assertThat(uut().isIdleNow).isTrue()
+ }
+
+ @Test fun `should be idle if the only timer is a repeating one`() {
+ givenTimer(aRepeatingTimer(MEANINGFUL_TIMER_INTERVAL))
+ assertThat(uut().isIdleNow).isTrue()
+ }
+
+ @Test fun `should be busy if a meaningful pending timer lies beyond a repeating one`() {
+ givenTimer(aRepeatingTimer(BUSY_INTERVAL_MS / 10))
+ givenTimer(aOneShotTimer(BUSY_INTERVAL_MS))
+ assertThat(uut().isIdleNow).isFalse()
+ }
+
+ @Test fun `should be idle if the only timer is overdue (due in the past)`() {
+ givenTimer(anOverdueTimer())
+ assertThat(uut().isIdleNow).isTrue()
+ }
+
+ @Test fun `should be busy if has a meaningful pending timer set beyond an overdue timer`() {
+ givenTimer(anOverdueTimer())
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+ assertThat(uut().isIdleNow).isFalse()
+ }
+
+ @Test fun `should be idle if paused`() {
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+
+ val uut = uut().apply {
+ pause()
+ }
+
+ assertThat(uut.isIdleNow).isTrue()
+ }
+
+ @Test fun `should be busy if paused and resumed`() {
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+
+ val uut = uut().apply {
+ pause()
+ resume()
+ }
+
+ assertThat(uut.isIdleNow).isFalse()
+ }
+
+ @Test fun `should notify of transition to idle upon pausing`() {
+ val callback = aIdlingResourceCallback()
+
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+
+ with(uut()) {
+ registerIdleTransitionCallback(callback)
+ pause()
+ }
+
+ verify(callback).onTransitionToIdle()
+ }
+
+ @Test fun `should enqueue an is-idle check using choreographer when a callback gets registered`() {
+ with(uut()) {
+ registerIdleTransitionCallback(mock())
+ }
+
+ verify(choreographer).postFrameCallback(any())
+ }
+
+ @Test fun `should transition to idle when preregistered choreographer is dispatched`() {
+ val callback = aIdlingResourceCallback()
+
+ uut().registerIdleTransitionCallback(callback)
+ invokeChoreographerCallback()
+
+ verify(callback).onTransitionToIdle()
+ }
+
+ @Test fun `should NOT transition to idle if not idle when preregistered choreographer is dispatched`() {
+ val callback = aIdlingResourceCallback()
+
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+
+ uut().registerIdleTransitionCallback(callback)
+ invokeChoreographerCallback()
+
+ verify(callback, never()).onTransitionToIdle()
+ }
+
+ @Test fun `should re-register choreographer if found idle while preregistered choreographer is dispatched`() {
+ val callback = aIdlingResourceCallback()
+
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+
+ val uut = uut()
+ uut.registerIdleTransitionCallback(callback)
+ invokeChoreographerCallback()
+
+ verify(choreographer, times(2)).postFrameCallback(any())
+ }
+
+ @Test fun `should adhere to pausing also when invoked via choreographer callback`() {
+ val callback = aIdlingResourceCallback()
+
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+
+ uut().apply {
+ pause()
+ registerIdleTransitionCallback(callback)
+ }
+ val runtimeChoreographerCallback = getChoreographerCallback()
+
+ reset(callback, choreographer)
+ runtimeChoreographerCallback.doFrame(0L)
+
+ verify(callback, never()).onTransitionToIdle()
+ verify(choreographer, never()).postFrameCallback(any())
+ }
+
+ @Test fun `should enqueue an additional idle check (using choreographer) if found busy`() {
+ givenTimer(aOneShotTimer(MEANINGFUL_TIMER_INTERVAL))
+ uut().isIdleNow
+ verify(choreographer).postFrameCallback(any())
+ }
+
+ @Test fun `should NOT enqueue an additional idle check (using choreographer) if found idle`() {
+ givenTimer(aOneShotTimer(BUSY_INTERVAL_MS + 1))
+ uut().isIdleNow
+ verify(choreographer, never()).postFrameCallback(any())
+ }
+
+ @Test fun `should yield to other threads using the timers module`() {
+ val executor = Executors.newSingleThreadExecutor()
+ var isIdle: Boolean? = null
+
+ synchronized(timersLock) {
+ executor.submit {
+ isIdle = uut().isIdleNow
+ }
+ executor.awaitTermination(100L, TimeUnit.MILLISECONDS)
+ assertThat(isIdle).isNull()
+ }
+ executor.awaitTermination(100L, TimeUnit.MILLISECONDS)
+ assertThat(isIdle).isNotNull()
+ }
+
+ private fun uut() = ReactNativeTimersIdlingResource(reactAppContext) { choreographer }
+
+ private fun givenTimer(timer: Any) {
+ pendingTimers.add(timer)
+ }
+
+ private fun invokeChoreographerCallback() {
+ getChoreographerCallback().doFrame(0L)
+ }
+
+ private fun getChoreographerCallback(): Choreographer.FrameCallback {
+ argumentCaptor().apply {
+ verify(choreographer).postFrameCallback(capture())
+ return firstValue
+ }
+ }
+}
diff --git a/detox/android/detox/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/detox/android/detox/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..1f0955d450
--- /dev/null
+++ b/detox/android/detox/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1 @@
+mock-maker-inline
diff --git a/detox/test/android/build.gradle b/detox/test/android/build.gradle
index 4fc0a7a28b..f49d0b9c7a 100644
--- a/detox/test/android/build.gradle
+++ b/detox/test/android/build.gradle
@@ -1,12 +1,17 @@
buildscript {
+ ext.kotlinVersion = '1.3.0'
+ ext.detoxKotlinVerion = ext.kotlinVersion
+
repositories {
google()
mavenLocal()
jcenter()
+ mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:3.1.4'
classpath 'de.undercouch:gradle-download-task:3.4.3'
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
}
}