RobolectricIdlingStrategy.android.kt
/*
* Copyright 2021 The Android Open Source Project
*
* 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 androidx.compose.ui.test.junit4
import androidx.test.espresso.AppNotIdleException
import androidx.test.espresso.IdlingPolicies
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Idling strategy for use with Robolectric.
*
* When running on Robolectric, the following things are different:
* 1. IdlingResources are not queried. We drive Compose from the ComposeIdlingResource, so we
* need to do that manually here.
* 2. Draw passes don't happen. Compose performs most measure and layout passes during the draw
* pass, so we need to manually trigger an actual measure/layout pass when needed.
* 3. Awaiting idleness must happen on the main thread. On Espresso it's exactly the other way
* around, so we need to invert our thread checks.
*
* Note that we explicitly don't install our [IdlingResourceRegistry] into Espresso even though
* it would be a noop anyway: if at some point in the future they will be supported, our behavior
* would silently change (potentially leading to breakages).
*/
internal class RobolectricIdlingStrategy(
private val composeRootRegistry: ComposeRootRegistry,
private val composeIdlingResource: ComposeIdlingResource
) : IdlingStrategy {
override val canSynchronizeOnUiThread: Boolean = true
override fun runUntilIdle() {
val policy = IdlingPolicies.getMasterIdlingPolicy()
val timeoutMillis = policy.idleTimeoutUnit.toMillis(policy.idleTimeout)
runOnUiThread {
// Use Java's clock, Android's clock is mocked
val start = System.currentTimeMillis()
var iteration = 0
do {
// Check if we hit the timeout
if (System.currentTimeMillis() - start >= timeoutMillis) {
throw AppNotIdleException.create(
emptyList(),
"Compose did not get idle after $iteration attempts in " +
"${policy.idleTimeout} ${policy.idleTimeoutUnit}. " +
"Please check your measure/layout lambdas, they may be " +
"causing an infinite composition loop. Or set Espresso's " +
"master idling policy if you require a longer timeout."
)
}
iteration++
// Run Espresso.onIdle() to drain the main message queue
runEspressoOnIdle()
// Check if we need a measure/layout pass
requestLayoutIfNeeded()
// Let ComposeIdlingResource fast-forward compositions
val isIdle = composeIdlingResource.isIdleNow
// Repeat while not idle
} while (!isIdle)
}
}
override suspend fun awaitIdle() {
// On Robolectric, Espresso.onIdle() must be called from the main thread; so use
// Dispatchers.Main. Use `.immediate` in case we're already on the main thread.
withContext(Dispatchers.Main.immediate) {
runUntilIdle()
}
}
/**
* Calls [requestLayout][android.view.View.requestLayout] on all compose hosts that are
* awaiting a measure/layout pass, because the draw pass that it is normally awaiting never
* happens on Robolectric.
*/
private fun requestLayoutIfNeeded(): Boolean {
val composeRoots = composeRootRegistry.getRegisteredComposeRoots()
return composeRoots.filter { it.hasPendingMeasureOrLayout }
.onEach { it.view.requestLayout() }
.isNotEmpty()
}
}