diff --git a/build-logic/build.gradle.kts b/build-logic/build.gradle.kts index 8acc2b4e999..01ebc0633e8 100644 --- a/build-logic/build.gradle.kts +++ b/build-logic/build.gradle.kts @@ -32,5 +32,9 @@ gradlePlugin { id = "io.sentry.spring-boot-matrix" implementationClass = "io.sentry.gradle.SpringBootMatrixPlugin" } + register("sentryRobolectricTestSplit") { + id = "io.sentry.robolectric-test-split" + implementationClass = "io.sentry.gradle.RobolectricTestSplitPlugin" + } } } diff --git a/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt b/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt new file mode 100644 index 00000000000..58c65303232 --- /dev/null +++ b/build-logic/src/main/kotlin/io/sentry/gradle/RobolectricTestSplitPlugin.kt @@ -0,0 +1,116 @@ +package io.sentry.gradle + +import com.android.build.api.variant.HostTest +import com.android.build.api.variant.HostTestBuilder +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import org.gradle.api.Action +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.file.FileTreeElement +import org.gradle.api.internal.tasks.testing.filter.DefaultTestFilter +import org.gradle.api.specs.Spec +import org.gradle.api.tasks.testing.Test +import org.gradle.language.base.plugins.LifecycleBasePlugin +import org.objectweb.asm.AnnotationVisitor +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type + +/** + * Splits an Android library's unit tests into two tasks, so only the Robolectric tests pay for a + * large heap and run in several forks: + * - `testUnitTest`, the AGP task, runs the Robolectric tests. Robolectric loads an + * android-all jar into each test JVM, which needs more heap than the default. + * - `testJvmUnitTest` runs the rest with the Gradle defaults. + * + * A Robolectric test is a class annotated with `@RunWith` and one of [ROBOLECTRIC_RUNNERS]. The + * annotation is read from the class itself, not from its superclasses. + * + * With `--tests`, the AGP task runs every matching test, so the IDE can still run a plain JVM test. + */ +class RobolectricTestSplitPlugin : Plugin { + override fun apply(project: Project) { + project.pluginManager.withPlugin("com.android.library") { + project.extensions.getByType(LibraryAndroidComponentsExtension::class.java).onVariants { + variant -> + variant.hostTests[HostTestBuilder.UNIT_TEST_TYPE]?.let { split(project, variant.name, it) } + } + } + } + + private fun split(project: Project, variantName: String, unitTest: HostTest) { + unitTest.configureTestTask { + it.maxHeapSize = "2g" + it.maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + it.doFirst(ExcludeJvmTestsUnlessFiltered) + } + + val variant = variantName.replaceFirstChar { it.uppercase() } + val robolectricTestsName = "test${unitTest.name.replaceFirstChar { it.uppercase() }}" + val jvmTests = + project.tasks.register("test${variant}JvmUnitTest", Test::class.java) { + description = "Runs the $variant unit tests that do not use Robolectric." + group = LifecycleBasePlugin.VERIFICATION_GROUP + val robolectricTests = project.tasks.named(robolectricTestsName, Test::class.java).get() + testClassesDirs = robolectricTests.testClassesDirs + classpath = robolectricTests.classpath + useJUnit() + exclude(RobolectricTestClass(robolectric = true)) + } + + project.tasks.matching { it.name == "test" }.configureEach { dependsOn(jvmTests) } + } +} + +private object ExcludeJvmTestsUnlessFiltered : Action { + override fun execute(task: Task) { + val test = task as Test + // Gradle has no public getter for the `--tests` patterns. + val commandLinePatterns = (test.filter as? DefaultTestFilter)?.commandLineIncludePatterns + if (commandLinePatterns.isNullOrEmpty()) { + test.exclude(RobolectricTestClass(robolectric = false)) + } + } +} + +private val ROBOLECTRIC_RUNNERS = + setOf( + "androidx/test/ext/junit/runners/AndroidJUnit4", + "org/robolectric/RobolectricTestRunner", + "org/robolectric/ParameterizedRobolectricTestRunner", + ) + +/** Matches the class files of Robolectric tests, or with [robolectric] false, all others. */ +private class RobolectricTestClass(private val robolectric: Boolean) : Spec { + override fun isSatisfiedBy(element: FileTreeElement): Boolean { + // Never match a directory, which would exclude everything inside it. + if (element.isDirectory || !element.name.endsWith(".class")) { + return false + } + return runsWithRobolectric(element.file.readBytes()) == robolectric + } + + private fun runsWithRobolectric(classFile: ByteArray): Boolean { + var found = false + val visitor = + object : ClassVisitor(Opcodes.ASM9) { + override fun visitAnnotation(descriptor: String, visible: Boolean): AnnotationVisitor? { + if (descriptor != "Lorg/junit/runner/RunWith;") { + return null + } + return object : AnnotationVisitor(Opcodes.ASM9) { + override fun visit(name: String?, value: Any?) { + if (value is Type && value.internalName in ROBOLECTRIC_RUNNERS) { + found = true + } + } + } + } + } + ClassReader(classFile) + .accept(visitor, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + return found + } +} diff --git a/sentry-android-core/build.gradle.kts b/sentry-android-core/build.gradle.kts index f78dd0c036b..e2d760c3f91 100644 --- a/sentry-android-core/build.gradle.kts +++ b/sentry-android-core/build.gradle.kts @@ -9,6 +9,7 @@ plugins { alias(libs.plugins.errorprone) alias(libs.plugins.gradle.versions) id("io.sentry.api-validation") + id("io.sentry.robolectric-test-split") } android { @@ -47,9 +48,6 @@ android { unitTests.apply { isReturnDefaultValues = true isIncludeAndroidResources = true - // Robolectric loads the android-all jar into each test JVM, which needs more heap - // than the default. - all { it.maxHeapSize = "2g" } } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt index 0c0c03d71d2..ca42381842a 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryLogcatAdapterTest.kt @@ -11,6 +11,7 @@ import io.sentry.SentryOptions import io.sentry.android.core.performance.AppStartMetrics import java.lang.RuntimeException import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -48,6 +49,12 @@ class SentryLogcatAdapterTest { private val fixture = Fixture() + @BeforeTest + fun `set up`() { + // Another test class in the same JVM may have cached app info without the DSN metadata. + ContextUtils.resetInstance() + } + @AfterTest fun `clean up`() { AppStartMetrics.getInstance().clear() diff --git a/sentry-android-replay/build.gradle.kts b/sentry-android-replay/build.gradle.kts index 6fff59a0c41..e5ea1e91c24 100644 --- a/sentry-android-replay/build.gradle.kts +++ b/sentry-android-replay/build.gradle.kts @@ -10,6 +10,7 @@ plugins { alias(libs.plugins.gradle.versions) alias(libs.plugins.detekt) id("io.sentry.api-validation") + id("io.sentry.robolectric-test-split") } android {