Skip to content

Commit

Permalink
Implement idling resources for tests. (#599)
Browse files Browse the repository at this point in the history
  • Loading branch information
m-sasha committed Jul 3, 2023
1 parent d63a7b6 commit cbbba40
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,10 @@ internal actual fun <T> 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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,10 @@ internal actual fun <T> 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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -48,3 +52,15 @@ internal actual fun <T> 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<timespec> {
tv_sec = timeMillis / 1000
tv_nsec = timeMillis.mod(1000L) * NanoSecondsPerMilliSecond
}

nanosleep(time, null)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -170,6 +179,8 @@ class SkikoComposeUiTest(
private val testOwner = DesktopTestOwner()
private val testContext = createTestContext(testOwner)

private val idlingResources = mutableSetOf<IdlingResource>()

fun <R> runTest(block: SkikoComposeUiTest.() -> R): R {
scene = runOnUiThread(::createUi)
try {
Expand Down Expand Up @@ -224,7 +235,7 @@ class SkikoComposeUiTest(
(it as SkiaRootForTest).hasPendingMeasureOrLayout
}

return !shouldPumpTime() && !hasPendingMeasureOrLayout
return !shouldPumpTime() && !hasPendingMeasureOrLayout && areAllResourcesIdle()
}

override fun waitForIdle() {
Expand All @@ -234,6 +245,9 @@ class SkikoComposeUiTest(
uncaughtExceptionHandler.throwUncaught()
renderNextFrame()
uncaughtExceptionHandler.throwUncaught()
if (!areAllResourcesIdle()) {
sleep(IDLING_RESOURCES_CHECK_INTERVAL_MS)
}
} while (!isIdle())
}

Expand All @@ -243,6 +257,9 @@ class SkikoComposeUiTest(
while (!isIdle()) {
renderNextFrame()
uncaughtExceptionHandler.throwUncaught()
if (!areAllResourcesIdle()) {
delay(IDLING_RESOURCES_CHECK_INTERVAL_MS)
}
yield()
}
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ internal expect fun <T> 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)

0 comments on commit cbbba40

Please sign in to comment.