From 1fbb57d7a13ef0ccebcfb9c0fc1de73856b4c123 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Fri, 10 Apr 2026 14:58:49 +0300 Subject: [PATCH] Add E2E integration test for Gradle plugin with Android project Add FeaturedPluginIntegrationTest that uses Gradle TestKit with a minimal Android application fixture to verify: - generateProguardRules produces correct -assumevalues rules - assembleRelease auto-wires the generated proguard file via Variant API Tests are skipped when ANDROID_HOME is not set. The testPluginClasspath configuration injects AGP into the TestKit subprocess classpath. Co-Authored-By: Claude Opus 4.6 --- featured-gradle-plugin/build.gradle.kts | 17 ++ .../fixtures/android-project/build.gradle.kts | 29 +++ .../android-project/settings.gradle.kts | 31 +++ .../src/main/AndroidManifest.xml | 1 + .../gradle/FeaturedPluginIntegrationTest.kt | 198 ++++++++++++++++++ 5 files changed, 276 insertions(+) create mode 100644 featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt diff --git a/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts index afde083..5da2b41 100644 --- a/featured-gradle-plugin/build.gradle.kts +++ b/featured-gradle-plugin/build.gradle.kts @@ -54,7 +54,24 @@ mavenPublishing { } } +// A separate configuration whose resolved jars are appended to the pluginUnderTestMetadata +// classpath. This makes GradleRunner.withPluginClasspath() inject them into the TestKit +// subprocess, which is necessary for compileOnly dependencies (like AGP) that the plugin +// needs at runtime but that java-gradle-plugin does not include from runtimeClasspath. +val testPluginClasspath: Configuration by configurations.creating { + isCanBeResolved = true + isCanBeConsumed = false + isVisible = false +} + +tasks.pluginUnderTestMetadata { + pluginClasspath.from(testPluginClasspath) +} + dependencies { + // Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured + // plugin can access AndroidComponentsExtension when wireProguardToVariants() is called. + testPluginClasspath("com.android.tools.build:gradle:9.1.0") testImplementation(gradleTestKit()) testImplementation(libs.kotlin.testJunit) testImplementation(libs.r8) diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts new file mode 100644 index 0000000..b0725d4 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + id("com.android.application") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "dev.androidbroadcast.featured.testapp" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + targetSdk = 36 + } + + buildTypes { + release { + isMinifyEnabled = true + // Featured plugin auto-wires its proguard rules via the AGP Variant API. + // A default keep file is required so R8 has something to keep. + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } +} + +featured { + localFlags { + boolean("dark_mode", default = false) + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts new file mode 100644 index 0000000..1540050 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts @@ -0,0 +1,31 @@ +// AGP and the Featured plugin are injected via GradleRunner.withPluginClasspath(). +// No pluginManagement repositories needed — the plugins are resolved from the injected classpath. +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "android-project" diff --git a/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/android-project/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt new file mode 100644 index 0000000..047cd55 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt @@ -0,0 +1,198 @@ +package dev.androidbroadcast.featured.gradle + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end integration test that verifies the Featured Gradle plugin: + * 1. Generates a ProGuard file at `build/featured/proguard-featured.pro` with correct + * `-assumevalues` rules for declared local flags. + * 2. Auto-wires that file into the AGP release variant so the `generateProguardRules` + * task participates in `assembleRelease`. + * + * The test uses a minimal Android application fixture copied from + * `src/test/fixtures/android-project/`. It runs via Gradle TestKit with the plugin + * classpath injected automatically by the `java-gradle-plugin` metadata. + * + * Skipped when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is not set — the test requires a + * real Android SDK to compile the AGP-driven release build. + */ +class FeaturedPluginIntegrationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setUp() { + val sdkDir = androidSdkDir() + assumeTrue( + "ANDROID_HOME or ANDROID_SDK_ROOT must be set to run integration tests", + sdkDir != null, + ) + + projectDir = tempFolder.newFolder("android-project") + copyFixture(projectDir) + + // Write local.properties with sdk.dir so AGP can locate the Android SDK. + projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.absolutePath}\n") + } + + // ── Tests ───────────────────────────────────────────────────────────────── + + @Test + fun `generateProguardRules task produces correct assumevalues rule for boolean local flag`() { + val result = + gradleRunner(projectDir) + .withArguments("generateProguardRules", "--stacktrace") + .build() + + val outcome = result.task(":generateProguardRules")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :generateProguardRules to succeed, got $outcome\n${result.output}", + ) + + val proFile = projectDir.resolve("build/featured/proguard-featured.pro") + assertTrue(proFile.exists(), "Expected proguard-featured.pro to be generated at ${proFile.path}") + + val content = proFile.readText() + assertContainsAssumevaluesBlock(content) + } + + @Test + fun `assembleRelease wires proguard rules and completes successfully`() { + val result = + gradleRunner(projectDir) + .withArguments("assembleRelease", "--stacktrace") + .build() + + // generateProguardRules must have run as part of the release build. + val proguardOutcome = result.task(":generateProguardRules")?.outcome + assertTrue( + proguardOutcome == TaskOutcome.SUCCESS || proguardOutcome == TaskOutcome.UP_TO_DATE, + "Expected :generateProguardRules to participate in assembleRelease, got $proguardOutcome\n${result.output}", + ) + + val assembleOutcome = result.task(":assembleRelease")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + assembleOutcome, + "Expected :assembleRelease to succeed, got $assembleOutcome\n${result.output}", + ) + + // Verify the .pro file content is correct even after the full build. + val proFile = projectDir.resolve("build/featured/proguard-featured.pro") + assertTrue(proFile.exists(), "Expected proguard-featured.pro to exist after assembleRelease") + assertContainsAssumevaluesBlock(proFile.readText()) + } + + // ── Assertions ──────────────────────────────────────────────────────────── + + /** + * Asserts that [content] contains a well-formed `-assumevalues` block targeting the + * extensions class for the root module (`:`) and the `dark_mode` boolean flag. + * + * Expected output (from [ProguardRulesGenerator]): + * ```proguard + * -assumevalues class dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt { + * boolean isDarkModeEnabled(dev.androidbroadcast.featured.ConfigValues) return false; + * } + * ``` + * + * The root module path `:` produces the identifier `Root` via [String.modulePathToIdentifier], + * so the JVM class name is `FeaturedRoot_FlagExtensionsKt`. + */ + private fun assertContainsAssumevaluesBlock(content: String) { + assertTrue( + content.contains("-assumevalues class $EXTENSIONS_FQN {"), + "Expected -assumevalues block targeting $EXTENSIONS_FQN\nActual content:\n$content", + ) + assertTrue( + content.contains("boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false;"), + "Expected 'boolean $IS_DARK_MODE_ENABLED($CONFIG_VALUES_FQN) return false;' in rules\nActual content:\n$content", + ) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /** Returns the Android SDK directory from environment, or null if not set. */ + private fun androidSdkDir(): File? { + val path = + System.getenv("ANDROID_HOME")?.takeIf { it.isNotBlank() } + ?: System.getenv("ANDROID_SDK_ROOT")?.takeIf { it.isNotBlank() } + ?: return null + return File(path).takeIf { it.isDirectory } + } + + /** + * Copies the fixture project from `src/test/fixtures/android-project/` into [dest]. + * + * The fixture is located relative to the plugin module's project directory, which + * Gradle TestKit passes as the working directory when running tests. + */ + private fun copyFixture(dest: File) { + val fixtureSource = fixtureDir() + fixtureSource + .walkTopDown() + .filter { it.isFile } + .forEach { file -> + val relative = file.relativeTo(fixtureSource) + val target = dest.resolve(relative) + target.parentFile?.mkdirs() + file.copyTo(target, overwrite = true) + } + } + + /** + * Resolves the fixture directory. The plugin module's project directory is either + * injected as the `user.dir` system property by Gradle's test task, or derived + * relative to this class file's location. + */ + private fun fixtureDir(): File { + // Gradle's test task sets user.dir to the module project directory. + val moduleDir = File(System.getProperty("user.dir")) + val candidate = moduleDir.resolve("src/test/fixtures/android-project") + require(candidate.isDirectory) { + "Fixture directory not found at ${candidate.absolutePath}. " + + "Expected it relative to module project dir: ${moduleDir.absolutePath}" + } + return candidate + } + + /** + * Creates a [GradleRunner] for the fixture project. + * + * AGP is declared as `compileOnly` in this module — the applying build provides it at runtime. + * In the TestKit subprocess, AGP is loaded by the build's own classloader when `com.android.application` + * is applied from the fixture's `plugins {}` block (resolved from Google Maven). The Featured plugin + * code that references [com.android.build.api.variant.AndroidComponentsExtension] is loaded in the + * same classloader context, so no extra classpath injection is needed. + */ + private fun gradleRunner(projectDir: File): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() + + // ── Constants ───────────────────────────────────────────────────────────── + + private companion object { + // The fixture is a single-project (root) build. + // modulePathToIdentifier(":") → "Root" → jvmFileName → "FeaturedRoot_FlagExtensionsKt" + const val EXTENSIONS_FQN = + "dev.androidbroadcast.featured.generated.FeaturedRoot_FlagExtensionsKt" + const val CONFIG_VALUES_FQN = "dev.androidbroadcast.featured.ConfigValues" + const val IS_DARK_MODE_ENABLED = "isDarkModeEnabled" + } +}