Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions featured-gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AGP version is hard-coded as 9.1.0 in the new testPluginClasspath dependency. Since the repo already tracks the AGP version in the version catalog (libs.versions.agp), this should be sourced from there to avoid accidental drift (tests may break if the plugin’s compileOnly AGP version changes but this stays pinned).

Suggested change
testPluginClasspath("com.android.tools.build:gradle:9.1.0")
testPluginClasspath("com.android.tools.build:gradle:${libs.versions.agp.get()}")

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Hardcoded agp in testpluginclasspath 📘 Rule violation ⚙ Maintainability

The build script adds an AGP dependency with a literal version string (9.1.0) instead of sourcing
the version from the Gradle version catalog. This can lead to version drift and violates the
requirement to declare dependency versions centrally.
Agent Prompt
## Issue description
A dependency version is hardcoded as `9.1.0` in `featured-gradle-plugin/build.gradle.kts`, violating the requirement to declare dependency versions in the Gradle version catalog.

## Issue Context
`gradle/libs.versions.toml` already defines `agp = "9.1.0"`, but the build script still hardcodes the version in the dependency coordinate.

## Fix Focus Areas
- featured-gradle-plugin/build.gradle.kts[74-74]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

testImplementation(gradleTestKit())
testImplementation(libs.kotlin.testJunit)
testImplementation(libs.r8)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
plugins {
id("com.android.application") version "9.1.0"
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fixture applies com.android.application with an explicit version. With TestKit, this typically resolves/loads AGP via plugin resolution rather than from the injected withPluginClasspath() classpath, while this PR also injects com.android.tools.build:gradle into the plugin-under-test classpath. That combination can create classloader/type-identity issues (e.g., AndroidComponentsExtension loaded twice) and make Variant API lookups/casts fail. Prefer applying com.android.application without a version here so it uses the injected classpath, keeping AGP types consistent.

Suggested change
id("com.android.application") version "9.1.0"
id("com.android.application")

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Fixture uses plugin version literal 📘 Rule violation ⚙ Maintainability

The fixture project applies com.android.application with a hardcoded version (9.1.0) in `plugins
{}`. This violates the requirement to avoid literal version strings in Gradle build scripts.
Agent Prompt
## Issue description
The TestKit fixture `build.gradle.kts` applies `com.android.application` with a hardcoded plugin version string (`9.1.0`).

## Issue Context
This fixture is used for TestKit E2E tests and should not hardcode versions in the build script. If AGP is injected via the TestKit classpath, the fixture can typically apply the plugin without specifying a version.

## Fix Focus Areas
- featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts[1-4]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

id("dev.androidbroadcast.featured")
}
Comment on lines +1 to +4
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Agp double-resolution risk 🐞 Bug ☼ Reliability

AGP is appended to the TestKit plugin classpath via pluginUnderTestMetadata, but the fixture still
applies com.android.application with an explicit version. This can cause Gradle plugin resolution
to fail because the plugin is already on the classpath while also being requested with a version.
Agent Prompt
## Issue description
AGP is injected into the TestKit subprocess classpath via `pluginUnderTestMetadata`, but the fixture still requests the Android plugin with an explicit version. This can break Gradle’s plugin resolution (plugin already on classpath + versioned request).

## Issue Context
The goal of `testPluginClasspath` is to make AGP available in the TestKit subprocess without relying on external plugin resolution.

## Fix Focus Areas
- featured-gradle-plugin/build.gradle.kts[57-75]
- featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts[1-4]
- featured-gradle-plugin/src/test/fixtures/android-project/settings.gradle.kts[1-15]

## What to change
- Prefer one mechanism:
  1) **Injected-classpath approach (recommended here):**
     - Change fixture to `plugins { id("com.android.application"); id("dev.androidbroadcast.featured") }` (no version for AGP).
     - Optionally simplify/remove `pluginManagement.repositories` if it’s no longer needed.
  OR
  2) **Repository resolution approach:**
     - Remove AGP from `testPluginClasspath` injection and keep the fixture’s versioned plugin request.

Ensure the chosen approach is consistent with the comments in the fixture/settings files.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


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)
}
}
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -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}",
Comment on lines +73 to +83
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assembleRelease test currently expects :generateProguardRules to run as part of the Android release build, but FeaturedPlugin only registers the generateProguardRules task and does not wire it into any Android variant/task graph (no Variant API wiring in FeaturedPlugin). As-is, assembleRelease in the fixture won't invoke generateProguardRules, so this test should fail unless the wiring code is included in this PR branch. Either include the wiring change (from the dependent PR) in this branch or change the fixture/build to explicitly add the generated .pro file to proguardFiles(...) and adjust the assertion accordingly.

Suggested change
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}",
fun `generateProguardRules and assembleRelease complete successfully`() {
val result =
gradleRunner(projectDir)
.withArguments("generateProguardRules", "assembleRelease", "--stacktrace")
.build()
// Explicitly invoke generateProguardRules before assembleRelease for the current plugin behavior.
val proguardOutcome = result.task(":generateProguardRules")?.outcome
assertTrue(
proguardOutcome == TaskOutcome.SUCCESS || proguardOutcome == TaskOutcome.UP_TO_DATE,
"Expected :generateProguardRules to succeed before assembleRelease, got $proguardOutcome\n${result.output}",

Copilot uses AI. Check for mistakes.
)
Comment on lines +79 to +84
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Assemblerelease not wired 🐞 Bug ≡ Correctness

FeaturedPluginIntegrationTest asserts :generateProguardRules runs during assembleRelease, but
the Featured plugin currently only registers the task and does not wire it into AGP variants, and
the fixture does not include the generated .pro file in proguardFiles. This will cause the test
to fail because result.task(":generateProguardRules") will be null or not executed during
assembleRelease.
Agent Prompt
## Issue description
The new E2E test expects `assembleRelease` to trigger `generateProguardRules`, but the plugin/fixture currently do not wire the generated ProGuard file into AGP’s release variant. This makes the test fail because `:generateProguardRules` will not be in the task graph for `assembleRelease`.

## Issue Context
- The plugin registers `generateProguardRules` and writes `build/featured/proguard-featured.pro`.
- The fixture’s `release` build type does not reference this file.
- There is no Android Components/Variant API integration in the plugin to add this file automatically.

## Fix Focus Areas
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt[37-58]
- featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt[99-111]
- featured-gradle-plugin/src/test/fixtures/android-project/build.gradle.kts[15-22]
- featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPluginIntegrationTest.kt[72-97]

## What to change
1. Implement actual AGP integration in the Featured plugin so that for release variants it:
   - adds `build/featured/proguard-featured.pro` to the variant’s ProGuard/R8 configuration, and
   - ensures the relevant R8/minify task depends on `generateProguardRules` (or otherwise consumes its output via the Variant API).
2. Keep the fixture minimal (it should not manually add the file if the plugin is supposed to auto-wire it).
3. If auto-wiring is not intended yet, adjust the test and fixture accordingly (but then remove the “auto-wires” claim and the `assembleRelease` participation assertion).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


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
Comment on lines +156 to +169
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The KDoc for fixtureDir() says the module directory is either provided via user.dir or derived relative to this class file's location, but the implementation only uses System.getProperty("user.dir") and has no fallback. Either implement the described fallback (e.g., resolve via a classpath resource) or update the KDoc to match the actual behavior to avoid confusion and brittle test setup.

Copilot uses AI. Check for mistakes.
}

/**
* 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.
Comment on lines +175 to +179
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gradleRunner() KDoc claims “no extra classpath injection is needed”, but this PR adds testPluginClasspath to inject AGP into pluginUnderTestMetadata. This comment is now misleading (and the classloader explanation is likely incorrect in TestKit). Please update the KDoc to reflect the actual mechanism being used so future changes don’t accidentally remove required classpath setup.

Suggested change
* 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.
* The runner uses [GradleRunner.withPluginClasspath] so TestKit loads the plugin-under-test from
* the generated `pluginUnderTestMetadata` classpath.
*
* This test setup also relies on additional classpath wiring for that metadata so AGP is available
* to the plugin under test, even though AGP is declared as `compileOnly` in this module. Keep that
* wiring in place when changing the test configuration; removing it can break access to
* [com.android.build.api.variant.AndroidComponentsExtension] during the TestKit build.

Copilot uses AI. Check for mistakes.
*/
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"
}
}
Loading