EspressoLink.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.ui.test.junit4.android.ComposeNotIdleException
import androidx.test.espresso.AppNotIdleException
import androidx.test.espresso.Espresso
import androidx.test.espresso.IdlingRegistry
import androidx.test.espresso.IdlingResource
import androidx.test.espresso.IdlingResourceTimeoutException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

/**
 * Idling strategy for regular Android Instrumented tests, built on Espresso.
 *
 * This installs the [IdlingResourceRegistry] as an [IdlingResource] into Espresso and delegates
 * all the work to Espresso. We wrap [Espresso.onIdle] so we can print more informative error
 * messages.
 */
internal class EspressoLink(
    private val registry: IdlingResourceRegistry
) : IdlingResource, IdlingStrategy {

    override val canSynchronizeOnUiThread: Boolean = false

    override fun getName(): String = "Compose-Espresso link"

    override fun isIdleNow(): Boolean {
        return registry.isIdleOrEnsurePolling()
    }

    override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) {
        registry.setOnIdleCallback {
            callback?.onTransitionToIdle()
        }
    }

    fun getDiagnosticMessageIfBusy(): String? = registry.getDiagnosticMessageIfBusy()

    override fun <R> withStrategy(block: () -> R): R {
        @Suppress("DEPRECATION") // See comment below
        try {
            // TODO(b/205550018): remove usage of deprecated API when b/205550018 is fixed
            // When (un)registering via IdlingRegistry, the resource will only be removed
            // from IdlingResourceRegistry when the two sources of truth are synced with
            // each other, which only happens in interactions with UiController and would
            // thus require awaiting quiescence (e.g. Espresso.onIdle())
            // However, the deprecated (un)register methods on Espresso also trigger a sync
            // between the two sources of truth, which means we don't have to do an onIdle()
            Espresso.registerIdlingResources(this@EspressoLink)
            return block()
        } finally {
            Espresso.unregisterIdlingResources(this@EspressoLink)
        }
    }

    override fun runUntilIdle() {
        check(!isOnUiThread()) {
            "Functions that involve synchronization (Assertions, Actions, Synchronization; " +
                "e.g. assertIsSelected(), doClick(), runOnIdle()) cannot be run " +
                "from the main thread. Did you nest such a function inside " +
                "runOnIdle {}, runOnUiThread {} or setContent {}?"
        }
        runEspressoOnIdle()
    }

    override suspend fun awaitIdle() {
        // Espresso.onIdle() must be called from a non-ui thread; so use Dispatchers.IO
        withContext(Dispatchers.IO) {
            runUntilIdle()
        }
    }
}

internal fun runEspressoOnIdle() {
    try {
        Espresso.onIdle()
    } catch (e: Throwable) {

        // Happens on the global time out, usually when the global timeout (the master policy)
        // is less than or equal to the idling resource timeout or when the timeout is not due to
        // an individual idling resource. This does not necessarily mean that it can't be due to
        // an idling resource being busy. So we try to check if it failed due to compose being busy
        // and add some extra information to the developer.
        val appNotIdleMaybe = tryToFindCause<AppNotIdleException>(e)
        if (appNotIdleMaybe != null) {
            rethrowWithMoreInfo(appNotIdleMaybe, wasGlobalTimeout = true)
        }

        // Happens on idling resource taking too long. Espresso gives out which resources caused
        // it but it won't allow us to give any extra information. So we check if it was our
        // resource and give more info if we can.
        val resourceNotIdleMaybe = tryToFindCause<IdlingResourceTimeoutException>(e)
        if (resourceNotIdleMaybe != null) {
            rethrowWithMoreInfo(resourceNotIdleMaybe, wasGlobalTimeout = false)
        }

        // No match, rethrow
        throw e
    }
}

private fun rethrowWithMoreInfo(e: Throwable, wasGlobalTimeout: Boolean) {
    var diagnosticInfo = ""
    val listOfIdlingResources = mutableListOf<String>()
    IdlingRegistry.getInstance().resources.forEach { resource ->
        if (resource is EspressoLink) {
            val message = resource.getDiagnosticMessageIfBusy()
            if (message != null) {
                diagnosticInfo += "$message \n"
            }
        }
        listOfIdlingResources.add(resource.name)
    }
    if (diagnosticInfo.isNotEmpty()) {
        val prefix = if (wasGlobalTimeout) {
            "Global time out"
        } else {
            "Idling resource timed out"
        }
        throw ComposeNotIdleException(
            "$prefix: possibly due to compose being busy.\n" +
                diagnosticInfo +
                "All registered idling resources: " +
                listOfIdlingResources.joinToString(", "),
            e
        )
    }
    // No extra info, re-throw the original exception
    throw e
}

/**
 * Tries to find if the given exception or any of its cause is of the type of the provided
 * throwable T. Returns null if there is no match. This is required as some exceptions end up
 * wrapped in Runtime or Concurrent exceptions.
 */
private inline fun <reified T : Throwable> tryToFindCause(e: Throwable): Throwable? {
    var causeToCheck: Throwable? = e
    while (causeToCheck != null) {
        if (causeToCheck is T) {
            return causeToCheck
        }
        causeToCheck = causeToCheck.cause
    }
    return null
}