diff --git a/build.gradle b/build.gradle index 6504a88c7e..daab27269f 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ buildscript { espresso: 'androidx.test.espresso:espresso-core:3.1.0', rules: 'androidx.test:rules:1.1.0', runner: 'androidx.test:runner:1.1.0', + orchestrator: 'androidx.test:orchestrator:1.1.0', ], ], android_support: 'com.android.support:support-v4:28.0.0', diff --git a/docs/recipes.md b/docs/recipes.md index b127e9903a..4cd3f6edae 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -139,7 +139,12 @@ android { // ... testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArgument "listener", "leakcanary.FailTestOnLeakRunListener" + testInstrumentationRunnerArgument "listener", "leakcanary.FailTestOnLeakRunListener" + + // If you're using Android Test Orchestrator + testOptions { + execution 'ANDROIDX_TEST_ORCHESTRATOR' + } } } ``` diff --git a/leakcanary-android-instrumentation/src/main/java/androidx/test/orchestrator/instrumentationlistener/OrchestratedInstrumentationListenerSpy.kt b/leakcanary-android-instrumentation/src/main/java/androidx/test/orchestrator/instrumentationlistener/OrchestratedInstrumentationListenerSpy.kt new file mode 100644 index 0000000000..9f67ec060d --- /dev/null +++ b/leakcanary-android-instrumentation/src/main/java/androidx/test/orchestrator/instrumentationlistener/OrchestratedInstrumentationListenerSpy.kt @@ -0,0 +1,20 @@ +package androidx.test.orchestrator.instrumentationlistener + +import android.os.Bundle +import androidx.test.orchestrator.callback.OrchestratorCallback + +internal fun OrchestratedInstrumentationListener.delegateSendTestNotification( + onSendTestNotification: (Bundle, (Bundle) -> Unit) -> Unit +) { + val realCallback = odoCallback + + val sendTestNotificationCallback: (Bundle) -> Unit = { bundle -> + realCallback.sendTestNotification(bundle) + } + + odoCallback = object : OrchestratorCallback by realCallback { + override fun sendTestNotification(bundle: Bundle) { + onSendTestNotification(bundle, sendTestNotificationCallback) + } + } +} diff --git a/leakcanary-android-instrumentation/src/main/java/leakcanary/FailTestOnLeakRunListener.kt b/leakcanary-android-instrumentation/src/main/java/leakcanary/FailTestOnLeakRunListener.kt index 2c24698c4c..ad478a42df 100644 --- a/leakcanary-android-instrumentation/src/main/java/leakcanary/FailTestOnLeakRunListener.kt +++ b/leakcanary-android-instrumentation/src/main/java/leakcanary/FailTestOnLeakRunListener.kt @@ -15,13 +15,14 @@ */ package leakcanary -import android.app.Instrumentation +import android.app.Activity +import android.app.Application +import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle import android.util.Log -import androidx.test.internal.runner.listener.InstrumentationResultPrinter -import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_VALUE_RESULT_FAILURE import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import leakcanary.InstrumentationLeakDetector.Result.AnalysisPerformed +import leakcanary.internal.TestResultPublisher import org.junit.runner.Description import org.junit.runner.Result import org.junit.runner.notification.Failure @@ -30,6 +31,10 @@ import shark.HeapAnalysis import shark.HeapAnalysisFailure import shark.HeapAnalysisSuccess import shark.SharkLog +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Proxy +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit.SECONDS /** * @@ -43,23 +48,58 @@ import shark.SharkLog * @see InstrumentationLeakDetector */ open class FailTestOnLeakRunListener : RunListener() { - private lateinit var bundle: Bundle + private var _currentTestDescription: Description? = null + private val currentTestDescription: Description + get() = _currentTestDescription!! + private var skipLeakDetectionReason: String? = null + private lateinit var testResultPublisher: TestResultPublisher + + @Volatile + private var allActivitiesDestroyedLatch: CountDownLatch? = null + + override fun testRunStarted(description: Description) { + InstrumentationLeakDetector.updateConfig() + testResultPublisher = TestResultPublisher.install() + trackActivities() + } + + private fun trackActivities() { + val instrumentation = getInstrumentation()!! + val application = instrumentation.targetContext.applicationContext as Application + application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks by noOpDelegate() { + + var activitiesWaitingForDestroyed = 0 + + override fun onActivityCreated( + activity: Activity, + savedInstanceState: Bundle? + ) { + if (activitiesWaitingForDestroyed == 0) { + allActivitiesDestroyedLatch = CountDownLatch(1) + } + activitiesWaitingForDestroyed++ + } + + override fun onActivityDestroyed(activity: Activity) { + activitiesWaitingForDestroyed-- + if (activitiesWaitingForDestroyed == 0) { + allActivitiesDestroyedLatch!!.countDown() + } + } + }) + } + + override fun testRunFinished(result: Result) { + } + override fun testStarted(description: Description) { + _currentTestDescription = description skipLeakDetectionReason = skipLeakDetectionReason(description) if (skipLeakDetectionReason != null) { return } - val testClass = description.className - val testName = description.methodName - - bundle = Bundle() - bundle.putString( - Instrumentation.REPORT_KEY_IDENTIFIER, FailTestOnLeakRunListener::class.java.name - ) - bundle.putString(InstrumentationResultPrinter.REPORT_KEY_NAME_CLASS, testClass) - bundle.putString(InstrumentationResultPrinter.REPORT_KEY_NAME_TEST, testName) } /** @@ -84,21 +124,21 @@ open class FailTestOnLeakRunListener : RunListener() { } override fun testFinished(description: Description) { - detectLeaks() + if (skipLeakDetectionReason == null) { + detectLeaks() + } else { + SharkLog.d { "Skipping leak detection because the test $skipLeakDetectionReason" } + skipLeakDetectionReason = null + } AppWatcher.objectWatcher.clearWatchedObjects() + _currentTestDescription = null + testResultPublisher.publishTestFinished() } - override fun testRunStarted(description: Description) { - InstrumentationLeakDetector.updateConfig() - } - - override fun testRunFinished(result: Result) {} - private fun detectLeaks() { - if (skipLeakDetectionReason != null) { - SharkLog.d { "Skipping leak detection because the test $skipLeakDetectionReason" } - skipLeakDetectionReason = null - return + val allActivitiesDestroyed = allActivitiesDestroyedLatch?.await(2, SECONDS) ?: true + if (!allActivitiesDestroyed) { + SharkLog.d { "Leak detection proceeding with some activities still not in destroyed state" } } val leakDetector = InstrumentationLeakDetector() @@ -130,10 +170,19 @@ open class FailTestOnLeakRunListener : RunListener() { } /** - * Reports that the test has failed, with the provided [message]. + * Reports that the test has failed, with the provided [trace]. */ - protected fun failTest(message: String) { - bundle.putString(InstrumentationResultPrinter.REPORT_KEY_STACK, message) - getInstrumentation().sendStatus(REPORT_VALUE_RESULT_FAILURE, bundle) + protected fun failTest(trace: String) { + testResultPublisher.publishTestFailure(currentTestDescription, trace) + } + + private inline fun noOpDelegate(): T { + val javaClass = T::class.java + val noOpHandler = InvocationHandler { _, _, _ -> + // no op + } + return Proxy.newProxyInstance( + javaClass.classLoader, arrayOf(javaClass), noOpHandler + ) as T } } diff --git a/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/InstrumentationTestResultPublisher.kt b/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/InstrumentationTestResultPublisher.kt new file mode 100644 index 0000000000..a4d01964d1 --- /dev/null +++ b/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/InstrumentationTestResultPublisher.kt @@ -0,0 +1,30 @@ +package leakcanary.internal + +import android.app.Instrumentation.REPORT_KEY_IDENTIFIER +import android.os.Bundle +import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_KEY_NAME_CLASS +import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_KEY_NAME_TEST +import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_KEY_STACK +import androidx.test.internal.runner.listener.InstrumentationResultPrinter.REPORT_VALUE_RESULT_FAILURE +import androidx.test.platform.app.InstrumentationRegistry +import leakcanary.FailTestOnLeakRunListener +import org.junit.runner.Description + +internal class InstrumentationTestResultPublisher : TestResultPublisher { + override fun publishTestFinished() { + } + + override fun publishTestFailure( + description: Description, + trace: String + ) { + val bundle = Bundle() + bundle.putString(REPORT_KEY_IDENTIFIER, FailTestOnLeakRunListener::class.java.name) + bundle.putString(REPORT_KEY_NAME_CLASS, description.className) + bundle.putString(REPORT_KEY_NAME_TEST, description.methodName) + bundle.putString(REPORT_KEY_STACK, trace) + + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.sendStatus(REPORT_VALUE_RESULT_FAILURE, bundle) + } +} diff --git a/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/OrchestratorTestResultPublisher.kt b/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/OrchestratorTestResultPublisher.kt new file mode 100644 index 0000000000..6dd86d41d1 --- /dev/null +++ b/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/OrchestratorTestResultPublisher.kt @@ -0,0 +1,75 @@ +package leakcanary.internal + +import android.os.Bundle +import androidx.test.orchestrator.instrumentationlistener.OrchestratedInstrumentationListener +import androidx.test.orchestrator.instrumentationlistener.delegateSendTestNotification +import androidx.test.orchestrator.junit.ParcelableDescription +import androidx.test.orchestrator.junit.ParcelableFailure +import androidx.test.orchestrator.junit.ParcelableResult +import androidx.test.orchestrator.listeners.OrchestrationListenerManager.KEY_TEST_EVENT +import androidx.test.orchestrator.listeners.OrchestrationListenerManager.TestEvent.TEST_FAILURE +import androidx.test.orchestrator.listeners.OrchestrationListenerManager.TestEvent.TEST_FINISHED +import androidx.test.orchestrator.listeners.OrchestrationListenerManager.TestEvent.TEST_RUN_FINISHED +import org.junit.runner.Description + +internal class OrchestratorTestResultPublisher(listener: OrchestratedInstrumentationListener) : + TestResultPublisher { + + private var sendTestFinished: (() -> Unit)? = null + + private var failureBundle: Bundle? = null + + private var receivedTestFinished: Boolean = false + + init { + val failures = mutableListOf() + listener.delegateSendTestNotification { testEventBundle, sendTestNotification -> + + when (testEventBundle.getString(KEY_TEST_EVENT)) { + TEST_FINISHED.toString() -> { + sendTestFinished = { + failureBundle?.let { failureBundle -> + failures += failureBundle.get("failure") as ParcelableFailure + sendTestNotification(failureBundle) + } + sendTestNotification(testEventBundle) + // reset for next test if any. + sendTestFinished = null + failureBundle = null + receivedTestFinished = false + } + if (receivedTestFinished) { + sendTestFinished!!.invoke() + } + } + TEST_RUN_FINISHED.toString() -> { + if (failures.isNotEmpty()) { + val result = testEventBundle.get("result") as ParcelableResult + result.failures += failures + } + sendTestNotification(testEventBundle) + } + else -> sendTestNotification(testEventBundle) + } + } + } + + override fun publishTestFinished() { + receivedTestFinished = true + sendTestFinished?.invoke() + } + + override fun publishTestFailure( + description: Description, + trace: String + ) { + val result = Bundle() + val failure = ParcelableFailure( + ParcelableDescription(description), + RuntimeException(trace) + ) + result.putParcelable("failure", failure) + result.putString(KEY_TEST_EVENT, TEST_FAILURE.toString()) + this.failureBundle = result + } +} diff --git a/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/TestResultPublisher.kt b/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/TestResultPublisher.kt new file mode 100644 index 0000000000..00fa3407fd --- /dev/null +++ b/leakcanary-android-instrumentation/src/main/java/leakcanary/internal/TestResultPublisher.kt @@ -0,0 +1,37 @@ +package leakcanary.internal + +import androidx.test.orchestrator.instrumentationlistener.OrchestratedInstrumentationListener +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.AndroidJUnitRunner +import org.junit.runner.Description +import shark.SharkLog + +internal interface TestResultPublisher { + + fun publishTestFinished() + + fun publishTestFailure( + description: Description, + trace: String + ) + + companion object { + fun install(): TestResultPublisher { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val orchestratorListener = if (instrumentation is AndroidJUnitRunner) { + AndroidJUnitRunner::class.java.getDeclaredField("orchestratorListener") + .run { + isAccessible = true + get(instrumentation) as OrchestratedInstrumentationListener? + } + } else null + return if (orchestratorListener != null) { + SharkLog.d { "Android Test Orchestrator detected, failures will be sent via binder callback" } + OrchestratorTestResultPublisher(orchestratorListener) + } else { + SharkLog.d { "Failures will be sent via Instrumentation.sendStatus()" } + InstrumentationTestResultPublisher() + } + } + } +} diff --git a/leakcanary-android-sample/build.gradle b/leakcanary-android-sample/build.gradle index 2bb0d639ce..92d378bce9 100644 --- a/leakcanary-android-sample/build.gradle +++ b/leakcanary-android-sample/build.gradle @@ -25,6 +25,7 @@ dependencies { androidTestImplementation deps.androidx.test.espresso androidTestImplementation deps.androidx.test.rules androidTestImplementation deps.androidx.test.runner + androidTestUtil deps.androidx.test.orchestrator } android { @@ -44,8 +45,14 @@ android { versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - testInstrumentationRunnerArgument "listener", - "leakcanary.FailTestOnLeakRunListener" + testInstrumentationRunnerArgument "listener", "leakcanary.FailTestOnLeakRunListener" + + // Run ./gradlew leakcanary-android-sample:connectedCheck -Porchestrator + if (project.hasProperty('orchestrator')) { + testOptions { + execution 'ANDROIDX_TEST_ORCHESTRATOR' + } + } } buildTypes {