diff --git a/detox/android/build.gradle b/detox/android/build.gradle index 61c374195e..69862de24d 100644 --- a/detox/android/build.gradle +++ b/detox/android/build.gradle @@ -1,10 +1,14 @@ buildscript { + ext.kotlinVersion = '1.3.0' + ext.detoxKotlinVerion = ext.kotlinVersion + repositories { jcenter() google() } dependencies { classpath 'com.android.tools.build:gradle:3.1.4' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } 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..bb9a1f7ba2 --- /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 anIdlingResourceCallback() = 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 = anIdlingResourceCallback() + + with(uut()) { + registerIdleTransitionCallback(callback) + isIdleNow + } + + verify(callback).onTransitionToIdle() + } + + @Test fun `should NOT transition to idle if found busy in query`() { + val callback = anIdlingResourceCallback() + + 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 = anIdlingResourceCallback() + + 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 = anIdlingResourceCallback() + + uut().registerIdleTransitionCallback(callback) + invokeChoreographerCallback() + + verify(callback).onTransitionToIdle() + } + + @Test fun `should NOT transition to idle if not idle when preregistered choreographer is dispatched`() { + val callback = anIdlingResourceCallback() + + 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 = anIdlingResourceCallback() + + 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 = anIdlingResourceCallback() + + 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/app/build.gradle b/detox/test/android/app/build.gradle index 6130f7ebb8..eff5dfc844 100644 --- a/detox/test/android/app/build.gradle +++ b/detox/test/android/app/build.gradle @@ -1,4 +1,4 @@ -apply plugin: "com.android.application" +apply plugin: 'com.android.application' apply from: "../../node_modules/react-native/react.gradle" diff --git a/detox/test/android/build.gradle b/detox/test/android/build.gradle index 4fc0a7a28b..77f09b1338 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() } + 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" } }