ComposeIdlingResource.android.kt
/*
* Copyright 2020 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.compose.runtime.Recomposer
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.ui.platform.ViewRootForTest
import androidx.compose.ui.test.IdlingResource
/**
* Provides an idle check to be registered into Espresso.
*
* This makes sure that Espresso is able to wait for any pending changes in Compose. This
* resource is automatically registered when any compose testing APIs are used including
* [createAndroidComposeRule].
*/
internal class ComposeIdlingResource(
private val composeRootRegistry: ComposeRootRegistry,
private val clock: MainTestClockImpl,
private val mainRecomposer: Recomposer
) : IdlingResource {
private var hadAwaitersOnMainClock = false
private var hadSnapshotChanges = false
private var hadRecomposerChanges = false
private var hadPendingSetContent = false
private var hadPendingMeasureLayout = false
override val isIdleNow: Boolean
get() {
fun shouldPumpTime(): Boolean {
hadAwaitersOnMainClock = clock.hasAwaiters
hadSnapshotChanges = Snapshot.current.hasPendingChanges()
hadRecomposerChanges = mainRecomposer.hasPendingWork
val needsRecompose = hadAwaitersOnMainClock || hadSnapshotChanges ||
hadRecomposerChanges
return clock.autoAdvance && needsRecompose
}
var i = 0
while (i < 100 && shouldPumpTime()) {
clock.advanceTimeByFrame()
++i
}
// pending set content needs all created compose roots,
// because by definition they will not be in resumed state
hadPendingSetContent =
composeRootRegistry.getCreatedComposeRoots().any { it.isBusyAttaching }
val composeRoots = composeRootRegistry.getRegisteredComposeRoots()
hadPendingMeasureLayout = composeRoots.any { it.hasPendingMeasureOrLayout }
return !shouldPumpTime() &&
!hadPendingSetContent &&
!hadPendingMeasureLayout
}
override fun getDiagnosticMessageIfBusy(): String? {
val wasBusy = hadSnapshotChanges || hadRecomposerChanges || hadAwaitersOnMainClock ||
hadPendingSetContent || hadPendingMeasureLayout
if (wasBusy) {
return null
}
val busyReasons = mutableListOf<String>()
val busyRecomposing = hadSnapshotChanges || hadRecomposerChanges || hadAwaitersOnMainClock
if (busyRecomposing) {
busyReasons.add("pending recompositions")
}
if (hadPendingSetContent) {
busyReasons.add("pending setContent")
}
if (hadPendingMeasureLayout) {
busyReasons.add("pending measure/layout")
}
var message = "${javaClass.simpleName} is busy due to ${busyReasons.joinToString(", ")}.\n"
if (busyRecomposing) {
message += "- Note: Timeout on pending recomposition means that there are most likely" +
" infinite re-compositions happening in the tested code.\n"
message += "- Debug: hadRecomposerChanges = $hadRecomposerChanges, "
message += "hadSnapshotChanges = $hadSnapshotChanges, "
message += "hadAwaitersOnMainClock = $hadAwaitersOnMainClock"
}
return message
}
}
internal val ViewRootForTest.isBusyAttaching: Boolean
get() {
// If the rootView has a parent, it is the ViewRootImpl, which is set in
// windowManager.addView(). If the rootView doesn't have a parent, the view hasn't been
// attached to a window yet, or is removed again.
return view.rootView.parent != null && !view.isAttachedToWindow
}