From 17efbe04df29bded8006c038ab90da811981c813 Mon Sep 17 00:00:00 2001 From: Alexander Maryanovsky Date: Tue, 13 Jun 2023 23:45:44 +0300 Subject: [PATCH] Implement idling resources for tests. (#599) --- .../junit4/DesktopSynchronization.desktop.kt | 7 +++ .../androidx/compose/ui/test/BasicTestTest.kt | 44 +++++++++++++++++++ .../ui/test/junit4/Synchronization.jsMain.kt | 7 +++ .../test/junit4/Synchronization.nativeMain.kt | 16 +++++++ .../ui/test/ComposeUiTest.skikoMain.kt | 31 +++++++++++-- .../test/junit4/Synchronization.skikoMain.kt | 7 +++ 6 files changed, 109 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopSynchronization.desktop.kt b/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopSynchronization.desktop.kt index ee407a04d5115..893f99c75eeec 100644 --- a/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopSynchronization.desktop.kt +++ b/compose/ui/ui-test-junit4/src/desktopMain/kotlin/androidx/compose/ui/test/junit4/DesktopSynchronization.desktop.kt @@ -45,3 +45,10 @@ internal actual fun runOnUiThread(action: () -> T): T { internal actual fun isOnUiThread(): Boolean { return SwingUtilities.isEventDispatchThread() } + +/** + * Blocks the calling thread for [timeMillis] milliseconds. + */ +internal actual fun sleep(timeMillis: Long) { + Thread.sleep(timeMillis) +} \ No newline at end of file diff --git a/compose/ui/ui-test-junit4/src/desktopTest/kotlin/androidx/compose/ui/test/BasicTestTest.kt b/compose/ui/ui-test-junit4/src/desktopTest/kotlin/androidx/compose/ui/test/BasicTestTest.kt index 7306d2b8aecec..0085f47fd463b 100644 --- a/compose/ui/ui-test-junit4/src/desktopTest/kotlin/androidx/compose/ui/test/BasicTestTest.kt +++ b/compose/ui/ui-test-junit4/src/desktopTest/kotlin/androidx/compose/ui/test/BasicTestTest.kt @@ -32,7 +32,10 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.atomicfu.atomic +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.junit.Rule @@ -137,4 +140,45 @@ class BasicTestTest { } } } + + @Test + fun testIdlingResource() { + var text by mutableStateOf("") + rule.setContent { + Text( + text = text, + modifier = Modifier.testTag("text") + ) + } + + var isIdle = true + val idlingResource = object: IdlingResource { + override val isIdleNow: Boolean + get() = isIdle + } + + fun test(expectedValue: String) { + text = "first" + isIdle = false + val job = CoroutineScope(Dispatchers.Default).launch { + delay(1000) + text = "second" + isIdle = true + } + try { + rule.onNodeWithTag("text").assertTextEquals(expectedValue) + } finally { + job.cancel() + } + } + + // With the idling resource registered, we expect the test to wait until the second value + // has been set. + rule.registerIdlingResource(idlingResource) + test(expectedValue = "second") + + // Without the idling resource registered, we expect the test to see the first value + rule.unregisterIdlingResource(idlingResource) + test(expectedValue = "first") + } } \ No newline at end of file diff --git a/compose/ui/ui-test-junit4/src/jsMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.jsMain.kt b/compose/ui/ui-test-junit4/src/jsMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.jsMain.kt index d20a6100b2606..a9cb7ac00b331 100644 --- a/compose/ui/ui-test-junit4/src/jsMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.jsMain.kt +++ b/compose/ui/ui-test-junit4/src/jsMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.jsMain.kt @@ -29,3 +29,10 @@ internal actual fun runOnUiThread(action: () -> T): T { * Returns if the call is made on the main thread. */ internal actual fun isOnUiThread(): Boolean = true + +/** + * Throws an [UnsupportedOperationException]. + */ +internal actual fun sleep(timeMillis: Long) { + throw UnsupportedOperationException("sleep is not supported in JS target") +} diff --git a/compose/ui/ui-test-junit4/src/nativeMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.nativeMain.kt b/compose/ui/ui-test-junit4/src/nativeMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.nativeMain.kt index 0d1e6230cf1c3..83fcf84e7881b 100644 --- a/compose/ui/ui-test-junit4/src/nativeMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.nativeMain.kt +++ b/compose/ui/ui-test-junit4/src/nativeMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.nativeMain.kt @@ -16,13 +16,17 @@ package androidx.compose.ui.test.junit4 +import androidx.compose.ui.test.NanoSecondsPerMilliSecond import kotlin.coroutines.suspendCoroutine +import kotlinx.cinterop.cValue import kotlinx.coroutines.runBlocking import platform.Foundation.NSDate import platform.Foundation.NSDefaultRunLoopMode import platform.Foundation.NSRunLoop import platform.Foundation.performBlock import platform.Foundation.runMode +import platform.posix.nanosleep +import platform.posix.timespec /** * Runs the given action on the UI thread. @@ -48,3 +52,15 @@ internal actual fun runOnUiThread(action: () -> T): T { * Returns if the call is made on the main thread. */ internal actual fun isOnUiThread(): Boolean = NSRunLoop.currentRunLoop === NSRunLoop.mainRunLoop + +/** + * Blocks the calling thread for [timeMillis] milliseconds. + */ +internal actual fun sleep(timeMillis: Long) { + val time = cValue { + tv_sec = timeMillis / 1000 + tv_nsec = timeMillis.mod(1000L) * NanoSecondsPerMilliSecond + } + + nanosleep(time, null) +} diff --git a/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt b/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt index c036542d3b9ec..70b0018efde17 100644 --- a/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt +++ b/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/ComposeUiTest.skikoMain.kt @@ -27,9 +27,11 @@ import androidx.compose.ui.node.RootForTest import androidx.compose.ui.platform.InfiniteAnimationPolicy import androidx.compose.ui.platform.SkiaRootForTest import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.junit4.* import androidx.compose.ui.test.junit4.MainTestClockImpl import androidx.compose.ui.test.junit4.UncaughtExceptionHandler import androidx.compose.ui.test.junit4.isOnUiThread +import androidx.compose.ui.test.junit4.synchronized import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.input.* import androidx.compose.ui.unit.Constraints @@ -39,6 +41,7 @@ import kotlin.coroutines.EmptyCoroutineContext import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -68,6 +71,12 @@ fun runSkikoComposeUiTest( ).runTest(block) } +/** + * How often to check idling resources. + * Empirically checked that Android (espresso, really) tests approximately at this rate. + */ +private const val IDLING_RESOURCES_CHECK_INTERVAL_MS = 20L + /** * @param effectContext The [CoroutineContext] used to run the composition. The context for * `LaunchedEffect`s and `rememberCoroutineScope` will be derived from this context. @@ -170,6 +179,8 @@ class SkikoComposeUiTest( private val testOwner = DesktopTestOwner() private val testContext = createTestContext(testOwner) + private val idlingResources = mutableSetOf() + fun runTest(block: SkikoComposeUiTest.() -> R): R { scene = runOnUiThread(::createUi) try { @@ -224,7 +235,7 @@ class SkikoComposeUiTest( (it as SkiaRootForTest).hasPendingMeasureOrLayout } - return !shouldPumpTime() && !hasPendingMeasureOrLayout + return !shouldPumpTime() && !hasPendingMeasureOrLayout && areAllResourcesIdle() } override fun waitForIdle() { @@ -234,6 +245,9 @@ class SkikoComposeUiTest( uncaughtExceptionHandler.throwUncaught() renderNextFrame() uncaughtExceptionHandler.throwUncaught() + if (!areAllResourcesIdle()) { + sleep(IDLING_RESOURCES_CHECK_INTERVAL_MS) + } } while (!isIdle()) } @@ -243,6 +257,9 @@ class SkikoComposeUiTest( while (!isIdle()) { renderNextFrame() uncaughtExceptionHandler.throwUncaught() + if (!areAllResourcesIdle()) { + delay(IDLING_RESOURCES_CHECK_INTERVAL_MS) + } yield() } } @@ -275,11 +292,19 @@ class SkikoComposeUiTest( } override fun registerIdlingResource(idlingResource: IdlingResource) { - // TODO: implement + synchronized(idlingResources) { + idlingResources.add(idlingResource) + } } override fun unregisterIdlingResource(idlingResource: IdlingResource) { - // TODO: implement + synchronized(idlingResources) { + idlingResources.remove(idlingResource) + } + } + + private fun areAllResourcesIdle() = synchronized(idlingResources) { + idlingResources.all { it.isIdleNow } } override fun setContent(composable: @Composable () -> Unit) { diff --git a/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.skikoMain.kt b/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.skikoMain.kt index b6ddeda9a4b42..fbd8bbf78f101 100644 --- a/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.skikoMain.kt +++ b/compose/ui/ui-test-junit4/src/skikoMain/kotlin/androidx/compose/ui/test/junit4/Synchronization.skikoMain.kt @@ -27,3 +27,10 @@ internal expect fun runOnUiThread(action: () -> T): T * Returns if the call is made on the main thread. */ internal expect fun isOnUiThread(): Boolean + +/** + * Blocks the calling thread for the given number of milliseconds. + * + * On targets that don't support this, should throw an [UnsupportedOperationException]. + */ +internal expect fun sleep(timeMillis: Long)