diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index e5f8fba..eba2c0c 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -16,6 +16,7 @@ jobs: build: name: "Javaagent Gradle Plugin Validation" strategy: + fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} @@ -34,5 +35,25 @@ jobs: - name: Setup TestLens uses: testlens-app/setup-testlens@v1 + - name: Setup Android test SDK + uses: android-actions/setup-android@v4.0.4 + with: + packages: 'platforms;android-36 build-tools;36.0.0' + log-accepted-android-sdk-licenses: false + - name: Execute Gradle build run: ${{ matrix.platform == 'windows-latest' && '.\gradlew.bat' || './gradlew' }} build + + - name: Validate Android Gradle Plugin 8 compatibility + run: ${{ matrix.platform == 'windows-latest' && '.\gradlew.bat' || './gradlew' }} :android:functionalTest "-PandroidTestAgpVersion=8.13.2" + + - name: Upload Android test reports + if: always() + uses: actions/upload-artifact@v7.0.2 + with: + name: android-test-reports-${{ matrix.platform }} + path: | + android/build/reports/tests/functionalTest/ + android/build/test-results/functionalTest/ + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c9ee8e3..823bf88 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,12 @@ jobs: with: cache-read-only: false + - name: Setup Android test SDK + uses: android-actions/setup-android@v4.0.4 + with: + packages: 'platforms;android-36 build-tools;36.0.0' + log-accepted-android-sdk-licenses: false + - name: Execute Gradle build run: ./gradlew build diff --git a/README.md b/README.md index 509e89a..1ef3b7c 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,41 @@ dependencies { > ℹ️ **Note:** The [Javaagent Test Plugin](#java-test-task-integration) automatically resolves dependency versions for javaagents dependencies that did not request one from the test runtime classpath. +## Android JVM test integration + +The `com.ryandens.javaagent-test-android` plugin (available in the next release) attaches agents to +host-side JVM tests in Android application and library projects. Apply it instead of +`com.ryandens.javaagent-test` in Android modules: + +```kotlin +plugins { + id("com.android.application") // or com.android.library, using your project's AGP version + id("com.ryandens.javaagent-test-android") version "" +} + +dependencies { + testImplementation(platform("org.mockito:mockito-bom:5.18.0")) + testImplementation("org.mockito:mockito-core") + testJavaagent("org.mockito:mockito-core") +} +``` + +Agents declared in `javaagent` are inherited; options on `testJavaagent` can override shared options. +Versionless agents are resolved using each host test variant's runtime classpath, including its +flavor/build-type dependencies and BOM constraints. Custom flavors and build types are discovered +through Android's public Variant API. Either plugin order works, and neither the Java nor Android +plugin is applied implicitly. Device/emulator instrumentation tests and Android app packaging are +unaffected. + +Set `javaagentTest { enabled.set(false) }` or `-PjavaagentTestEnabled=false` to disable attachment. +The project property takes precedence. Disabled agents are not resolved. + +Compatibility is tested with AGP 8.13.2 / Gradle 9.5 and AGP 9.4.1 / Gradle 9.8.1, including +configuration-cache reuse and Isolated Projects. AGP 8 requires Gradle below 9.6 because it uses +an internal Gradle API removed in 9.6. To run this repository's Android functional tests, set +`ANDROID_HOME` (or `ANDROID_SDK_ROOT`) and install `platforms;android-36` and `build-tools;36.0.0`. +Run the compatibility suite with `./gradlew :android:functionalTest "-PandroidTestAgpVersion=8.13.2"`. + ## Application Plugin integration This Gradle plugin tightly integrates with the [Gradle application plugin](https://docs.gradle.org/current/userguide/application_plugin.html) diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..6f28a9e --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,66 @@ +import org.gradle.plugin.compatibility.compatibility + +plugins { + id("com.ryandens.plugin-conventions") +} + +repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("androidx\\..*") + includeGroup("com.google.testing.platform") + } + } +} + +// TestKit injects plugins through a separate classloader: include the matching AGP only for tests. +val androidTestPluginDependencies = configurations.dependencyScope("androidTestPluginDependencies") +val androidTestPlugin = + configurations.resolvable("androidTestPlugin") { + extendsFrom(androidTestPluginDependencies.get()) + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(Usage.JAVA_RUNTIME)) + attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.LIBRARY)) + attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, objects.named(LibraryElements.JAR)) + } + } + +dependencies { + implementation(project(":plugin")) + compileOnly("com.android.tools.build:gradle-api:8.13.2") + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit") + addProvider( + androidTestPluginDependencies.name, + providers.gradleProperty("androidTestAgpVersion").orElse("9.4.1").map { "com.android.tools.build:gradle:$it" }, + ) +} + +tasks.named("pluginUnderTestMetadata") { + pluginClasspath.from(androidTestPlugin) +} + +gradlePlugin { + plugins { + create("javaagentTestAndroidPlugin") { + id = "com.ryandens.javaagent-test-android" + displayName = "Javaagent Android Test Plugin" + description = "Automatically attaches javaagents to Android host-side JVM tests" + implementationClass = "com.ryandens.javaagent.android.JavaagentTestAndroidPlugin" + tags.set(listOf("javaagent", "instrumentation", "android", "test")) + compatibility { + features { configurationCache = true } + } + } + } +} + +tasks.named("functionalTest") { + testLogging.exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + inputs + .property( + "androidSdkHome", + providers.environmentVariable("ANDROID_HOME").orElse(providers.environmentVariable("ANDROID_SDK_ROOT")), + ).optional(true) +} diff --git a/android/src/functionalTest/kotlin/com/ryandens/javaagent/android/JavaagentTestAndroidPluginFunctionalTest.kt b/android/src/functionalTest/kotlin/com/ryandens/javaagent/android/JavaagentTestAndroidPluginFunctionalTest.kt new file mode 100644 index 0000000..80005df --- /dev/null +++ b/android/src/functionalTest/kotlin/com/ryandens/javaagent/android/JavaagentTestAndroidPluginFunctionalTest.kt @@ -0,0 +1,324 @@ +package com.ryandens.javaagent.android + +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import java.io.File +import java.nio.file.Files +import java.util.jar.Attributes +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import java.util.jar.Manifest +import javax.tools.ToolProvider +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class JavaagentTestAndroidPluginFunctionalTest { + private val directory = Files.createTempDirectory("android-javaagent-").toFile() + private val module = directory.resolve("android-module") + + // SDK initialization must not create an input directory after the first cache snapshot. + private val androidUserHome = directory.resolve("android-home").apply { resolve("cache").mkdirs() } + + @AfterTest fun cleanUp() { + directory.deleteRecursively() + } + + @Test fun `application variants infer their own versions and reuse the configuration cache`() { + verifyVariants("com.android.application", agpVersion, agentFirst = false) + } + + @Test fun `library variants support either plugin order`() { + verifyVariants("com.android.library", agpVersion, agentFirst = true) + } + + @Test fun `library variants support isolated projects`() { + verifyVariants("com.android.library", agpVersion, agentFirst = true, isolated = true) + } + + @Test fun `help neither realizes test tasks nor resolves agents`() { + createProject("com.android.application", agpVersion, agentFirst = true) + module.resolve("build.gradle").appendText( + """ + + tasks.withType(Test).configureEach { throw new GradleException('Test task realized by help') } + configurations.matching { it.name.endsWith('Javaagent') || it.name == 'javaagent' }.configureEach { + incoming.beforeResolve { throw new GradleException('Agent resolved by help') } + } + """, + ) + runBuild("help") + assertTrue(runBuild("help").output.contains("Reusing configuration cache.")) + } + + @Test fun `plugin does not apply Android or Java plugins implicitly`() { + directory.resolve("settings.gradle").writeText("rootProject.name = 'no-android'\n") + directory.resolve("build.gradle").writeText( + """ + plugins { id('com.ryandens.javaagent-test-android') } + assert !plugins.hasPlugin('java') + assert !plugins.hasPlugin('com.android.application') + assert !plugins.hasPlugin('com.android.library') + """, + ) + runBuild("help") + } + + @Test fun `extension can disable agents without resolving them`() { + verifyDisabled("javaagentTest { enabled = false }", emptyArray()) + } + + @Test fun `tests can run without any agents`() { + createProject("com.android.library", agpVersion, agentFirst = false) + writeTest(null) + runBuild(":android-module:testDemoDebugUnitTest") + } + + @Test fun `project property can disable agents without resolving them`() { + verifyDisabled("javaagentTest { enabled = true }", arrayOf("-PjavaagentTestEnabled=false")) + } + + @Test fun `project agent jar is built before Android tests`() { + createProject("com.android.library", agpVersion, agentFirst = false) + directory.resolve("settings.gradle").appendText("\ninclude(':agent')\n") + val agent = directory.resolve("agent") + agent.mkdirs() + agent.resolve("build.gradle").writeText( + """ + plugins { id('java') } + tasks.jar { + manifest.attributes('Premain-Class': 'fixture.Agent', 'Implementation-Version': 'project') + } + """, + ) + val source = agent.resolve("src/main/java/fixture/Agent.java") + source.parentFile.mkdirs() + source.writeText(agentSource) + module.resolve("build.gradle").appendText( + """ + + dependencies { + javaagent(project(':agent')) { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'android-options') } + } + } + """, + ) + writeTest("android-options") + val result = runBuild(":android-module:testDemoStagingUnitTest", "--parallel", "-Dorg.gradle.unsafe.isolated-projects=true") + assertEquals(TaskOutcome.SUCCESS, result.task(":agent:jar")?.outcome) + assertTrue(result.output.contains("Fixture agent: project=android-options")) + } + + private fun verifyVariants( + androidPlugin: String, + agpVersion: String, + agentFirst: Boolean, + isolated: Boolean = false, + ) { + createProject(androidPlugin, agpVersion, agentFirst) + publishAgent("1.0") + publishAgent("2.0") + module.resolve("build.gradle").appendText( + """ + + dependencies { + testImplementation(platform('fixture:agent-bom:1.0')) + testImplementation('fixture:test-agent') + javaagent('fixture:test-agent') { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'shared-options') } + } + testJavaagent('fixture:test-agent') { + attributes { attribute(com.ryandens.javaagent.AgentOptions.OPTIONS, 'android-options') } + } + } + androidComponents.onVariants(androidComponents.selector().all()) { variant -> + variant.hostTests.values().each { host -> + if (variant.buildType == 'staging') { + host.runtimeConfiguration.resolutionStrategy.force('fixture:test-agent:2.0') + } + } + } + """, + ) + writeTest("android-options") + val arguments = + listOf(":android-module:testDemoDebugUnitTest", ":android-module:testDemoStagingUnitTest", "--parallel", "--build-cache") + + if (isolated) listOf("-Dorg.gradle.unsafe.isolated-projects=true") else emptyList() + val result = runBuild(*arguments.toTypedArray()) + assertTrue(result.output.contains("Fixture agent: 1.0=android-options")) + assertTrue(result.output.contains("Fixture agent: 2.0=android-options")) + assertFalse(result.output.contains("Fixture agent: 1.0=shared-options")) + val cached = runBuild(*arguments.toTypedArray()) + assertTrue(cached.output.contains("Reusing configuration cache."), cached.output) + assertEquals(TaskOutcome.UP_TO_DATE, cached.task(":android-module:testDemoDebugUnitTest")?.outcome) + assertEquals(TaskOutcome.UP_TO_DATE, cached.task(":android-module:testDemoStagingUnitTest")?.outcome) + module.resolve("build").deleteRecursively() + val restored = runBuild(*arguments.toTypedArray()) + assertEquals(TaskOutcome.FROM_CACHE, restored.task(":android-module:testDemoDebugUnitTest")?.outcome) + assertEquals(TaskOutcome.FROM_CACHE, restored.task(":android-module:testDemoStagingUnitTest")?.outcome) + } + + private fun verifyDisabled( + extension: String, + arguments: Array, + ) { + createProject("com.android.library", agpVersion, agentFirst = true) + module.resolve("build.gradle").appendText( + """ + + $extension + dependencies { testJavaagent('missing:must-not-resolve:1.0') } + configurations.matching { it.name.endsWith('Javaagent') }.configureEach { + incoming.beforeResolve { throw new GradleException('Disabled agent was resolved') } + } + """, + ) + writeTest(null) + runBuild(":android-module:testDemoDebugUnitTest", *arguments) + } + + private fun createProject( + androidPlugin: String, + agpVersion: String, + agentFirst: Boolean, + ) { + val sdk = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT") + require(sdk != null && File(sdk, "platforms/android-36/android.jar").isFile) { + "Android functional tests require ANDROID_HOME with platforms;android-36 and build-tools;36.0.0 installed" + } + directory.resolve("settings.gradle").writeText( + """ + pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } } + dependencyResolutionManagement { + repositories { maven { url = uri('repo') }; google(); mavenCentral() } + } + rootProject.name = 'android-agent-tests' + include(':android-module') + """, + ) + module.mkdirs() + val android = "id('$androidPlugin') version '$agpVersion'" + val javaagent = "id('com.ryandens.javaagent-test-android')" + module.resolve("build.gradle").writeText( + """ + import com.android.build.api.variant.HostTestBuilder + plugins { ${if (agentFirst) "$javaagent; $android" else "$android; $javaagent"} } + android { + namespace = 'fixture.android' + compileSdk = 36 + buildToolsVersion = '36.0.0' + defaultConfig { minSdk = 23 } + flavorDimensions += 'tier' + productFlavors { demo { dimension = 'tier' } } + buildTypes { staging { initWith(debug) } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + testOptions { unitTests.all { testLogging.showStandardStreams = true } } + } + androidComponents.beforeVariants(androidComponents.selector().all()) { builder -> + builder.hostTests[HostTestBuilder.UNIT_TEST_TYPE].enable = true + if (builder.buildType == 'release') { builder.enable = false } + } + dependencies { testImplementation('junit:junit:4.13.2') } + assert !plugins.hasPlugin('java') + """, + ) + val manifest = module.resolve("src/main/AndroidManifest.xml") + manifest.parentFile.mkdirs() + manifest.writeText("") + } + + private fun writeTest(options: String?) { + val test = module.resolve("src/test/java/fixture/AgentTest.java") + test.parentFile.mkdirs() + val expected = options?.let { "\"$it\"" } ?: "null" + test.writeText( + """ + package fixture; + public class AgentTest { + @org.junit.Test public void agentIsConfigured() { + org.junit.Assert.assertEquals($expected, System.getProperty("fixture.agent.options")); + } + } + """, + ) + } + + private fun publishAgent(version: String) { + val source = directory.resolve("agent-source/fixture/Agent.java") + source.parentFile.mkdirs() + source.writeText(agentSource) + val classes = directory.resolve("agent-classes").apply { mkdirs() } + assertEquals(0, ToolProvider.getSystemJavaCompiler().run(null, null, null, "--release", "17", "-d", classes.path, source.path)) + val artifact = directory.resolve("repo/fixture/test-agent/$version/test-agent-$version") + artifact.parentFile.mkdirs() + val manifest = Manifest() + manifest.mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" + manifest.mainAttributes[Attributes.Name("Premain-Class")] = "fixture.Agent" + manifest.mainAttributes[Attributes.Name.IMPLEMENTATION_VERSION] = version + JarOutputStream(File("${artifact.path}.jar").outputStream(), manifest).use { jar -> + jar.putNextEntry(JarEntry("fixture/Agent.class")) + jar.write(classes.resolve("fixture/Agent.class").readBytes()) + jar.closeEntry() + } + File("${artifact.path}.pom").writeText( + "4.0.0fixturetest-agent$version", + ) + val bom = directory.resolve("repo/fixture/agent-bom/1.0/agent-bom-1.0.pom") + bom.parentFile.mkdirs() + bom.writeText( + """ + 4.0.0 + fixtureagent-bom1.0pom + + fixturetest-agent1.0 + + + """.trimIndent(), + ) + } + + private val agpVersion: String + get() = + GradleRunner + .create() + .withPluginClasspath() + .pluginClasspath + .single { it.name.startsWith("gradle-api-") && it.path.contains("com.android.tools.build") } + .name + .removePrefix("gradle-api-") + .removeSuffix(".jar") + + private fun runBuild(vararg arguments: String): BuildResult { + val runner = + GradleRunner + .create() + .withProjectDir(directory) + .withPluginClasspath() + .withEnvironment(System.getenv() + ("ANDROID_USER_HOME" to androidUserHome.absolutePath)) + .withArguments(*arguments, "--configuration-cache", "--stacktrace") + .forwardOutput() + // AGP 8 uses a Gradle internal API removed in 9.6; exercise its supported Gradle line. + if (agpVersion.startsWith("8.")) { + runner.withGradleVersion("9.5.0") + } + return runner.build() + } + + private val agentSource = + """ + package fixture; + public class Agent { + public static void premain(String options, java.lang.instrument.Instrumentation instrumentation) { + System.setProperty("fixture.agent.options", options == null ? "" : options); + System.out.println("Fixture agent: " + Agent.class.getPackage().getImplementationVersion() + "=" + options); + } + } + """ +} diff --git a/android/src/main/kotlin/com/ryandens/javaagent/android/JavaagentTestAndroidPlugin.kt b/android/src/main/kotlin/com/ryandens/javaagent/android/JavaagentTestAndroidPlugin.kt new file mode 100644 index 0000000..cf1ccf8 --- /dev/null +++ b/android/src/main/kotlin/com/ryandens/javaagent/android/JavaagentTestAndroidPlugin.kt @@ -0,0 +1,73 @@ +package com.ryandens.javaagent.android + +import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.HasHostTests +import com.ryandens.javaagent.AgentOptionsResolver +import com.ryandens.javaagent.JavaForkOptionsConfigurer +import com.ryandens.javaagent.JavaagentBasePlugin +import com.ryandens.javaagent.JavaagentPlugin +import com.ryandens.javaagent.JavaagentTestExtension +import com.ryandens.javaagent.JavaagentTestPlugin +import com.ryandens.javaagent.utils.JavaagentVersionUtil +import org.gradle.api.NamedDomainObjectProvider +import org.gradle.api.Project +import org.gradle.api.artifacts.Configuration +import org.gradle.api.file.FileCollection +import org.gradle.api.tasks.ClasspathNormalizer + +/** Attaches agents to Android JVM host tests without applying the incompatible Java plugin. */ +class JavaagentTestAndroidPlugin : JavaagentPlugin { + override fun applyAfterJavaagentSetup( + project: Project, + javaagentConfiguration: NamedDomainObjectProvider, + ) { + val testAgents = + JavaagentBasePlugin.targetConfiguration(project, JavaagentTestPlugin.CONFIGURATION_NAME, javaagentConfiguration) + val extension = project.extensions.create("javaagentTest", JavaagentTestExtension::class.java) + val enabled = + project.providers + .gradleProperty("javaagentTestEnabled") + .map(String::toBoolean) + .orElse(extension.enabled) + val emptyClasspath = project.objects.fileCollection() + val noClasspath = project.providers.provider { emptyClasspath } + val noOptions = project.providers.provider { emptyMap() } + + for (pluginId in listOf("com.android.application", "com.android.library")) { + project.pluginManager.withPlugin(pluginId) { + val components = project.extensions.getByType(AndroidComponentsExtension::class.java) + components.onVariants(components.selector().all()) { variant -> + for (hostTest in (variant as HasHostTests).hostTests.values) { + // Each host test resolves against its own runtime, including flavor/build-type versions. + val runtime = project.configurations.named(hostTest.runtimeConfiguration.name) + val agents = + JavaagentBasePlugin.targetConfiguration(project, "${hostTest.name}Javaagent", testAgents) + agents.configure { configuration -> + configuration.resolutionStrategy.eachDependency { dependency -> + if (dependency.requested.version.isNullOrBlank()) { + dependency.useVersion( + JavaagentVersionUtil.versionFromDependencyAndConfiguration(dependency.requested, runtime), + ) + } + } + } + val options = AgentOptionsResolver.optionsByFilePath(agents) + val enabledOptions = enabled.flatMap { if (it) options else noOptions } + // Preserve producing-task dependencies while excluding disabled configurations entirely. + val enabledClasspath = enabled.flatMap { if (it) agents else noClasspath } + val enabledFiles = enabledClasspath.map { it.files } + // AGP invokes this lazily, only when the actual variant's test task is configured. + hostTest.configureTestTask { test -> + test.inputs + .files(enabledClasspath) + .withPropertyName("javaagentClasspath") + .withNormalizer(ClasspathNormalizer::class.java) + test.inputs.property("agentOptions", enabledOptions) + JavaForkOptionsConfigurer.configureJavaForkOptions(test, enabledFiles, enabledOptions) + } + } + } + } + } + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 1916621..ccd8b33 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,7 +8,7 @@ plugins { } rootProject.name = "javaagent-plugin" -include("jib-common", "jib", "plugin", "simple-agent", "otel") +include("android", "jib-common", "jib", "plugin", "simple-agent", "otel") val isCI = providers.environmentVariable("CI").isPresent