From f766039f5573b3237b6002574a7caabc5ba3f9d7 Mon Sep 17 00:00:00 2001 From: Matthew Runo <74583+inktomi@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:04:44 -0800 Subject: [PATCH 1/6] Applied Android Studio JVM migration --- fladle-plugin/settings.gradle.kts | 4 ++++ gradle/gradle-daemon-jvm.properties | 13 +++++++++++++ gradle/libs.versions.toml | 1 + settings.gradle | 1 + 4 files changed, 19 insertions(+) create mode 100644 gradle/gradle-daemon-jvm.properties diff --git a/fladle-plugin/settings.gradle.kts b/fladle-plugin/settings.gradle.kts index 528a0fa6..09608f5d 100644 --- a/fladle-plugin/settings.gradle.kts +++ b/fladle-plugin/settings.gradle.kts @@ -1,5 +1,9 @@ rootProject.name = "fladle" +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + dependencyResolutionManagement { versionCatalogs { create("libs") { diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties new file mode 100644 index 00000000..5b343e54 --- /dev/null +++ b/gradle/gradle-daemon-jvm.properties @@ -0,0 +1,13 @@ +#This file is generated by updateDaemonJvm +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ecd23fd7707c683afbcd6052998cb6a9/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/0b98aec810298c2c1d7fdac5dac37910/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/658299a896470fbb3103ba3a430ee227/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/536afcd1dff540251f85e5d2c80458cf/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/67a0fee3c4236b6397dcbe8575ca2011/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/23adb857f3cb3cbe28750bc7faa7abc0/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/932015f6361ccaead0c6d9b8717ed96e/redirect +toolchainVendor=JETBRAINS +toolchainVersion=21 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ccba2c1..8a6ca392 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ ## Generated by $ ./gradlew refreshVersionsCatalog [plugins] +foojay = { id = "org.gradle.toolchains.foojay-resolver-convention", version = "1.0.0"} ben-manes-versions = { id = "com.github.ben-manes.versions", version = "0.51.0" } diff --git a/settings.gradle b/settings.gradle index bac20682..744f3555 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,6 +7,7 @@ pluginManagement { } plugins { + id "org.gradle.toolchains.foojay-resolver-convention" version "1.0.0" id "com.gradle.develocity" version "4.3" } From ade5d9f7265ed6abe7fd89f8a16f0cbe39fbd470 Mon Sep 17 00:00:00 2001 From: Matthew Runo <74583+inktomi@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:48:13 -0800 Subject: [PATCH 2/6] Updated project to use Android Gradle Plugin 9.0.1. Fixes #478 --- android-library-no-tests/build.gradle.kts | 1 - build.gradle | 3 +- fladle-plugin/build.gradle.kts | 18 +- .../com/osacky/flank/gradle/FladleConfig.kt | 7 +- .../flank/gradle/FladlePluginDelegate.kt | 207 +++++++++----- .../osacky/flank/gradle/FlankExecutionTask.kt | 9 +- .../flank/gradle/FlankGradleExtension.kt | 4 +- .../com/osacky/flank/gradle/FlankJavaExec.kt | 6 +- .../flank/gradle/FulladleModuleExtension.kt | 10 +- .../com/osacky/flank/gradle/FulladlePlugin.kt | 261 ++++++++++++------ .../RequiredDeviceKeyMissingException.kt | 4 +- .../flank/gradle/SanityConfigValidation.kt | 14 +- .../com/osacky/flank/gradle/VariantApkInfo.kt | 11 + .../java/com/osacky/flank/gradle/Variants.kt | 25 +- .../flank/gradle/YamlConfigWriterTask.kt | 11 +- .../com/osacky/flank/gradle/YamlExtensions.kt | 13 +- .../com/osacky/flank/gradle/YamlWriter.kt | 3 +- .../flank/gradle/validation/SinceFlank.kt | 5 +- .../gradle/validation/ValidateOptions.kt | 16 +- .../flank/gradle/validation/VersionNumber.kt | 47 ++-- .../flank/gradle/MultipleConfigsTest.kt | 6 +- .../integration/AutoConfigureFladleTest.kt | 3 +- .../integration/ConfigurationCacheTest.kt | 8 +- .../FlankGradlePluginIntegrationTest.kt | 48 ++-- .../FulladlePluginIntegrationTest.kt | 49 ++-- .../flank/gradle/integration/TestFixtures.kt | 9 +- .../flank/gradle/integration/VariantTests.kt | 21 +- .../validation/ValidateExclusionsTest.kt | 3 +- .../gradle/validation/ValidateOptionsTest.kt | 7 +- gradle.properties | 2 - gradle/libs.versions.toml | 8 +- gradle/wrapper/gradle-wrapper.properties | 2 +- sample-android-library/build.gradle.kts | 1 - sample-flavors-kotlin/build.gradle.kts | 1 - sample-kotlin/build.gradle.kts | 1 - sample/build.gradle | 1 - 36 files changed, 537 insertions(+), 308 deletions(-) create mode 100644 fladle-plugin/src/main/java/com/osacky/flank/gradle/VariantApkInfo.kt diff --git a/android-library-no-tests/build.gradle.kts b/android-library-no-tests/build.gradle.kts index b71a13a8..76dda11f 100644 --- a/android-library-no-tests/build.gradle.kts +++ b/android-library-no-tests/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id("com.android.library") - kotlin("android") } android { diff --git a/build.gradle b/build.gradle index a3cabd9d..165b5226 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,6 @@ buildscript { plugins { alias(libs.plugins.agp) apply false - alias(libs.plugins.kgp) apply false alias(libs.plugins.ben.manes.versions) id "com.osacky.fulladle" alias(libs.plugins.kotlinter) @@ -22,7 +21,7 @@ fladle { } tasks.wrapper.configure { - gradleVersion = '8.14.3' + gradleVersion = '9.1.0' } def isNonStable = { String version -> diff --git a/fladle-plugin/build.gradle.kts b/fladle-plugin/build.gradle.kts index b0fe4eb1..29d4e847 100644 --- a/fladle-plugin/build.gradle.kts +++ b/fladle-plugin/build.gradle.kts @@ -18,18 +18,18 @@ plugins { alias(libs.plugins.vanniktech.publish) } -// See https://github.com/slackhq/keeper/pull/11#issuecomment-579544375 for context -val isReleaseMode : Boolean = hasProperty("fladle.releaseMode") - dependencies { compileOnly(gradleApi()) - if (isReleaseMode) { - compileOnly(libs.agp) - } else { - implementation(libs.agp) + compileOnly(libs.agp) { + exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-embeddable") + exclude(group = "org.jetbrains.kotlin", module = "kotlin-compiler-runner") } compileOnly(libs.gradle.enterprise) + // AGP must be on the runtime classpath so GradleTestKit's withPluginClasspath() + // can resolve the com.android.application and com.android.library plugins. + runtimeOnly(libs.agp) + testImplementation(gradleTestKit()) testImplementation(libs.junit) testImplementation(libs.truth) @@ -106,8 +106,8 @@ tasks.withType(ValidatePlugins::class.java).configureEach { tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile::class.java).configureEach { compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_7) - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_1_7) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_0) } } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladleConfig.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladleConfig.kt index 869a9938..d6721998 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladleConfig.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladleConfig.kt @@ -51,13 +51,11 @@ interface FladleConfig { @get:Input val testTargets: ListProperty + // The maximum number of shards. Value will be overwritten by [maxTestShards] if both used in configuration @Deprecated( message = "testShards is deprecated. Use maxTestShards instead", replaceWith = ReplaceWith("maxTestShards"), ) - /** - * The maximum number of shards. Value will be overwritten by [maxTestShards] if both used in configuration - */ @get:Input @get:Optional val testShards: Property @@ -473,7 +471,8 @@ interface FladleConfig { @Internal fun getPresentProperties() = - this::class.memberProperties + this::class + .memberProperties .filter { when (val prop = it.call(this)) { is Property<*> -> prop.isPresent diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt index 6d3c1f92..249a2414 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt @@ -1,14 +1,15 @@ package com.osacky.flank.gradle -import com.android.build.gradle.AppExtension -import com.android.build.gradle.TestedExtension -import com.android.build.gradle.internal.tasks.factory.dependsOn -import com.android.builder.model.TestOptions +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.FilterConfiguration +import com.android.build.api.variant.HasAndroidTest import com.osacky.flank.gradle.validation.checkForExclusionUsage import com.osacky.flank.gradle.validation.validateOptionsUsed import org.gradle.api.GradleException import org.gradle.api.Project import org.gradle.api.artifacts.Configuration +import org.gradle.api.plugins.BasePluginExtension import org.gradle.api.tasks.TaskContainer import org.gradle.kotlin.dsl.create import org.gradle.util.GradleVersion @@ -24,7 +25,10 @@ class FladlePluginDelegate { target.tasks.register("flankAuth", FlankJavaExec::class.java) { doFirst { - target.layout.fladleDir.get().asFile.mkdirs() + target.layout.fladleDir + .get() + .asFile + .mkdirs() } classpath = project.fladleConfig args = listOf("auth", "login") @@ -34,7 +38,6 @@ class FladlePluginDelegate { } private fun checkMinimumGradleVersion() { - // Gradle 4.9 is required because we use the lazy task configuration API. if (GRADLE_MIN_VERSION > GradleVersion.current()) { throw GradleException("Fladle requires at minimum version $GRADLE_MIN_VERSION. Detected version ${GradleVersion.current()}.") } @@ -44,24 +47,22 @@ class FladlePluginDelegate { project: Project, base: FlankGradleExtension, ) { - if (GradleVersion.current() > GradleVersion.version("6.1")) { - base.flankVersion.finalizeValueOnRead() - base.flankCoordinates.finalizeValueOnRead() - base.serviceAccountCredentials.finalizeValueOnRead() + base.flankVersion.finalizeValueOnRead() + base.flankCoordinates.finalizeValueOnRead() + base.serviceAccountCredentials.finalizeValueOnRead() + + // Register onVariants callbacks before afterEvaluate for APK path detection + project.pluginManager.withPlugin("com.android.application") { + if (!base.debugApk.isPresent || !base.instrumentationApk.isPresent) { + findDebugAndInstrumentationApk(project, base) + } } + project.afterEvaluate { // Add Flank dependency to Fladle Configuration // Must be done afterEvaluate otherwise extension values will not be set. project.dependencies.add(FLADLE_CONFIG, "${base.flankCoordinates.get()}:${base.flankVersion.get()}") - // Only use automatic apk path detection for 'com.android.application' projects. - project.pluginManager.withPlugin("com.android.application") { - // This doesn't work properly for multiple configs since they likely are inheriting the config from root already. See #60 https://github.com/runningcode/fladle/issues/60 - if (!base.debugApk.isPresent || !base.instrumentationApk.isPresent) { - findDebugAndInstrumentationApk(project, base) - } - } - tasks.apply { createTasksForConfig(base, base, project, "") @@ -95,14 +96,21 @@ class FladlePluginDelegate { val writeConfigProps = register("writeConfigProps$name", YamlConfigWriterTask::class.java, base, config, name) - writeConfigProps.dependsOn(validateFladle) + writeConfigProps.configure { dependsOn(validateFladle) } register("printYml$name") { description = "Print the flank.yml file to the console." group = TASK_GROUP dependsOn(writeConfigProps) doLast { - println(writeConfigProps.get().fladleConfigFile.get().asFile.readText()) + println( + writeConfigProps + .get() + .fladleConfigFile + .get() + .asFile + .readText(), + ) } } @@ -110,7 +118,19 @@ class FladlePluginDelegate { if (useDefaultDir) setUpWorkingDir(configName) description = "Finds problems with the current configuration." classpath = project.fladleConfig - args = listOf("firebase", "test", "android", "doctor", "-c", writeConfigProps.get().fladleConfigFile.get().asFile.absolutePath) + args = + listOf( + "firebase", + "test", + "android", + "doctor", + "-c", + writeConfigProps + .get() + .fladleConfigFile + .get() + .asFile.absolutePath, + ) dependsOn(writeConfigProps) } @@ -122,13 +142,30 @@ class FladlePluginDelegate { args = if (project.hasProperty("dumpShards")) { listOf( - "firebase", "test", "android", "run", "-c", - writeConfigProps.get().fladleConfigFile.get().asFile.absolutePath, "--dump-shards", + "firebase", + "test", + "android", + "run", + "-c", + writeConfigProps + .get() + .fladleConfigFile + .get() + .asFile.absolutePath, + "--dump-shards", ) } else { listOf( - "firebase", "test", "android", "run", "-c", - writeConfigProps.get().fladleConfigFile.get().asFile.absolutePath, + "firebase", + "test", + "android", + "run", + "-c", + writeConfigProps + .get() + .fladleConfigFile + .get() + .asFile.absolutePath, ) } if (config.serviceAccountCredentials.isPresent) { @@ -136,17 +173,15 @@ class FladlePluginDelegate { } dependsOn(writeConfigProps) if (config.dependOnAssemble.isPresent && config.dependOnAssemble.get()) { - val testedExtension = - requireNotNull(project.extensions.findByType(TestedExtension::class.java)) { "Could not find TestedExtension in ${project.name}" } - testedExtension.testVariants.configureEach { - if (testedVariant.isExpectedVariant(config)) { - if (testedVariant.assembleProvider.isPresent) { - dependsOn(testedVariant.assembleProvider) - } - if (assembleProvider.isPresent) { - dependsOn(assembleProvider) - } - } + // Find assemble tasks by convention name pattern + val variantName = config.variant.orNull + if (variantName != null) { + val capitalizedVariant = variantName.capitalize() + dependsOn("assemble$capitalizedVariant") + dependsOn("assemble${capitalizedVariant}AndroidTest") + } else { + dependsOn("assembleDebug") + dependsOn("assembleDebugAndroidTest") } } if (config.localResultsDir.hasValue) { @@ -172,16 +207,17 @@ class FladlePluginDelegate { private fun automaticallyConfigureTestOrchestrator( project: Project, config: FladleConfig, - androidExtension: AppExtension, + androidExtension: ApplicationExtension, ) { project.afterEvaluate { + val execution = androidExtension.testOptions.execution.uppercase() val useOrchestrator = - androidExtension.testOptions.getExecutionEnum() == TestOptions.Execution.ANDROIDX_TEST_ORCHESTRATOR || - androidExtension.testOptions.getExecutionEnum() == TestOptions.Execution.ANDROID_TEST_ORCHESTRATOR + execution == "ANDROIDX_TEST_ORCHESTRATOR" || + execution == "ANDROID_TEST_ORCHESTRATOR" if (useOrchestrator) { log("Automatically detected the use of Android Test Orchestrator") + config.useOrchestrator.set(true) } - config.useOrchestrator.set(useOrchestrator) } } @@ -189,28 +225,75 @@ class FladlePluginDelegate { project: Project, config: FladleConfig, ) { - val baseExtension = - requireNotNull(project.extensions.findByType(AppExtension::class.java)) { "Could not find AppExtension in ${project.name}" } - automaticallyConfigureTestOrchestrator(project, config, baseExtension) - baseExtension.testVariants.configureEach { - val appVariant = testedVariant - outputs.configureEach test@{ - appVariant.outputs - .matching { it.isExpectedAbiOutput(config) } - .configureEach app@{ - if (appVariant.isExpectedVariant(config)) { - if (!config.debugApk.isPresent) { - // Don't set debug apk if not already set. #172 - project.log("Configuring fladle.debugApk from variant ${this@app.name}") - config.debugApk.set(this@app.outputFile.absolutePath) - } - if (!config.roboScript.isPresent && !config.instrumentationApk.isPresent && !config.sanityRobo.get()) { - // Don't set instrumentation apk if not already set. #172 - project.log("Configuring fladle.instrumentationApk from variant ${this@test.name}") - config.instrumentationApk.set(this@test.outputFile.absolutePath) - } - } + val androidExtension = + requireNotNull( + project.extensions.findByType(ApplicationExtension::class.java), + ) { "Could not find ApplicationExtension in ${project.name}" } + automaticallyConfigureTestOrchestrator(project, config, androidExtension) + + val androidComponents = + requireNotNull(project.extensions.findByType(ApplicationAndroidComponentsExtension::class.java)) { + "Could not find ApplicationAndroidComponentsExtension in ${project.name}" + } + + androidComponents.onVariants { variant -> + if (!variant.isExpectedVariant(config)) return@onVariants + val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + + val buildType = variant.buildType ?: return@onVariants + val flavorName = variant.productFlavors.joinToString("") { it.second } + val flavorPath = variant.productFlavors.joinToString("/") { it.second } + val archivesName = + project.extensions + .getByType(BasePluginExtension::class.java) + .archivesName + .get() + val buildDir = project.layout.buildDirectory + + // Test APK path + val testApkDirPath = if (flavorPath.isNotEmpty()) "androidTest/$flavorPath/$buildType" else "androidTest/$buildType" + val testApkFileName = + if (flavorName.isNotEmpty()) { + "$archivesName-$flavorName-$buildType-androidTest.apk" + } else { + "$archivesName-$buildType-androidTest.apk" + } + val testApkPath = + buildDir + .file("outputs/apk/$testApkDirPath/$testApkFileName") + .get() + .asFile.absolutePath + + variant.outputs.forEach { output -> + if (!output.isExpectedAbiOutput(config)) return@forEach + + val abiFilter = output.filters.firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI } + val abiName = abiFilter?.identifier + + val appApkDirPath = if (flavorPath.isNotEmpty()) "$flavorPath/$buildType" else buildType + val appApkFileName = + buildString { + append(archivesName) + if (flavorName.isNotEmpty()) append("-$flavorName") + if (abiName != null) append("-$abiName") + append("-$buildType.apk") } + val appApkPath = + buildDir + .file("outputs/apk/$appApkDirPath/$appApkFileName") + .get() + .asFile.absolutePath + + if (!config.debugApk.isPresent) { + // Don't set debug apk if not already set. #172 + project.log("Configuring fladle.debugApk from variant ${variant.name}") + config.debugApk.set(appApkPath) + } + if (!config.roboScript.isPresent && !config.instrumentationApk.isPresent && !config.sanityRobo.get()) { + // Don't set instrumentation apk if not already set. #172 + project.log("Configuring fladle.instrumentationApk from variant ${variant.name}") + config.instrumentationApk.set(testApkPath) + } } } } @@ -219,7 +302,7 @@ class FladlePluginDelegate { get() = configurations.getByName(FLADLE_CONFIG) companion object { - val GRADLE_MIN_VERSION: GradleVersion = GradleVersion.version("7.3") + val GRADLE_MIN_VERSION: GradleVersion = GradleVersion.version("9.1") const val TASK_GROUP = "fladle" const val FLADLE_CONFIG = "fladle" diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankExecutionTask.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankExecutionTask.kt index 168c5712..2518ff9d 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankExecutionTask.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankExecutionTask.kt @@ -8,7 +8,7 @@ import javax.inject.Inject @DisableCachingByDefault( because = "Flank executions are dependent on resources such as network connection and server and therefore cannot be cached.", ) -open class FlankExecutionTask +abstract class FlankExecutionTask @Inject constructor( projectLayout: ProjectLayout, @@ -22,7 +22,12 @@ open class FlankExecutionTask private fun checkFilesExist(base: FladleConfig) { if (base.serviceAccountCredentials.isPresent) { - check(base.serviceAccountCredentials.get().asFile.exists()) { + check( + base.serviceAccountCredentials + .get() + .asFile + .exists(), + ) { "serviceAccountCredential file doesn't exist ${base.serviceAccountCredentials.get()}" } } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt index 19c0f938..25b496d1 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankGradleExtension.kt @@ -16,7 +16,9 @@ import javax.inject.Inject open class FlankGradleExtension @Inject - constructor(objects: ObjectFactory) : FladleConfig { + constructor( + objects: ObjectFactory, + ) : FladleConfig { companion object { const val FLANK_VERSION = "23.10.1" } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt index f10ef850..21bb73aa 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt @@ -8,9 +8,11 @@ import javax.inject.Inject @DisableCachingByDefault( because = "Flank executions are dependent on resources such as network connection and server and therefore cannot be cached.", ) -open class FlankJavaExec +abstract class FlankJavaExec @Inject - constructor(projectLayout: ProjectLayout) : JavaExec() { + constructor( + projectLayout: ProjectLayout, + ) : JavaExec() { init { group = FladlePluginDelegate.TASK_GROUP mainClass.set("ftl.Main") diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleExtension.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleExtension.kt index 510f5f54..e6637bd1 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleExtension.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleExtension.kt @@ -9,7 +9,9 @@ import javax.inject.Inject open class FulladleModuleExtension @Inject - constructor(objects: ObjectFactory) { + constructor( + objects: ObjectFactory, + ) { /** * When set to false, Fulladle will not automatically add this module to additionalTestApks. * @@ -46,4 +48,10 @@ open class FulladleModuleExtension * can be a match. */ val variant: Property = objects.property().convention(null as String?) + + /** + * Variant APK info collected during configuration via onVariants callbacks. + * Used by FulladlePlugin at execution time to build YAML entries. + */ + internal val variantApks: MutableList = mutableListOf() } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt index 568feebb..59be14be 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt @@ -1,8 +1,12 @@ package com.osacky.flank.gradle -import com.android.build.gradle.TestedExtension +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.FilterConfiguration +import com.android.build.api.variant.HasAndroidTest +import com.android.build.api.variant.LibraryAndroidComponentsExtension import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.plugins.BasePluginExtension import org.gradle.kotlin.dsl.getByType /** @@ -18,23 +22,107 @@ class FulladlePlugin : Plugin { root.subprojects { // Yuck, cross project configuration extensions.create("fulladleModuleConfig", FulladleModuleExtension::class.java) + + // Register onVariants callbacks to capture APK info during configuration + pluginManager.withPlugin("com.android.application") { + val androidComponents = extensions.getByType(ApplicationAndroidComponentsExtension::class.java) + val ext = extensions.findByType(FulladleModuleExtension::class.java) ?: return@withPlugin + androidComponents.onVariants { variant -> + val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + val buildType = variant.buildType ?: return@onVariants + val flavorName = variant.productFlavors.joinToString("") { it.second } + val flavorPath = variant.productFlavors.joinToString("/") { it.second } + val archivesName = extensions.getByType(BasePluginExtension::class.java).archivesName.get() + + variant.outputs.forEach { output -> + val abiFilter = output.filters.firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI } + val abiName = abiFilter?.identifier + + val appApkDirPath = if (flavorPath.isNotEmpty()) "$flavorPath/$buildType" else buildType + val appApkFileName = + buildString { + append(archivesName) + if (flavorName.isNotEmpty()) append("-$flavorName") + if (abiName != null) append("-$abiName") + append("-$buildType.apk") + } + val appApkPath = + layout.buildDirectory + .file("outputs/apk/$appApkDirPath/$appApkFileName") + .get() + .asFile.absolutePath + + val testApkDirPath = + if (flavorPath.isNotEmpty()) "androidTest/$flavorPath/$buildType" else "androidTest/$buildType" + val testApkFileName = + if (flavorName.isNotEmpty()) { + "$archivesName-$flavorName-$buildType-androidTest.apk" + } else { + "$archivesName-$buildType-androidTest.apk" + } + val testApkPath = + layout.buildDirectory + .file("outputs/apk/$testApkDirPath/$testApkFileName") + .get() + .asFile.absolutePath + + ext.variantApks.add( + VariantApkInfo( + variantName = variant.name, + appApkPath = appApkPath, + testApkPath = testApkPath, + abiName = abiName, + ), + ) + } + } + } + + pluginManager.withPlugin("com.android.library") { + val androidComponents = extensions.getByType(LibraryAndroidComponentsExtension::class.java) + val ext = extensions.findByType(FulladleModuleExtension::class.java) ?: return@withPlugin + androidComponents.onVariants { variant -> + val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + val buildType = variant.buildType ?: return@onVariants + val flavorName = variant.productFlavors.joinToString("") { it.second } + val flavorPath = variant.productFlavors.joinToString("/") { it.second } + val archivesName = extensions.getByType(BasePluginExtension::class.java).archivesName.get() + + val testApkDirPath = + if (flavorPath.isNotEmpty()) "androidTest/$flavorPath/$buildType" else "androidTest/$buildType" + val testApkFileName = + if (flavorName.isNotEmpty()) { + "$archivesName-$flavorName-$buildType-androidTest.apk" + } else { + "$archivesName-$buildType-androidTest.apk" + } + val testApkPath = + layout.buildDirectory + .file("outputs/apk/$testApkDirPath/$testApkFileName") + .get() + .asFile.absolutePath + + ext.variantApks.add( + VariantApkInfo( + variantName = variant.name, + appApkPath = null, + testApkPath = testApkPath, + abiName = null, + ), + ) + } + } } val fulladleConfigureTask = root.tasks.register("configureFulladle") { var modulesEnabled = false - /** - * we will first configure all app modules - * then configure all library modules - * we force this order of configuration because - * app modules are better candidates to become - * root level test/app APKs, since they produce - * app APKs - * if no app module had tests or was enabled - * we will choose a library module to become - * a root level module, in which case we will - * have to check if it has its debugApk set - */ + // We first configure all app modules, then configure all library modules. + // We force this order because app modules are better candidates to become + // root level test/app APKs, since they produce app APKs. + // If no app module had tests or was enabled, we will choose a library module + // to become a root level module, in which case we will have to check if it + // has its debugApk set. doLast { // first configure all app modules root.subprojects { @@ -86,82 +174,80 @@ fun configureModule( return } - val testedExtension = extensions.findByType(TestedExtension::class.java) ?: return // Only configure the first test variant per module. // Does anyone test more than one variant per module? var addedTestsForModule = false - testedExtension.testVariants.configureEach testVariant@{ - if (this.isExpectedVariantInModule(fulladleModuleExtension)) { - testedVariant.outputs - .matching { it.isExpectedAbiOutput(flankGradleExtension) } - .configureEach app@{ - if (addedTestsForModule) { - return@app - } - this@testVariant.outputs.configureEach test@{ - val yml = StringBuilder() - // If the debugApk isn't yet set, let's use this one. - if (!flankGradleExtension.debugApk.isPresent) { - if (project.isAndroidAppModule) { - // app modules produce app apks that we can consume - flankGradleExtension.debugApk.set(rootProject.provider { this@app.outputFile.absolutePath }) - } else if (project.isAndroidLibraryModule) { - // library modules do not produce an app apk and we'll use the one specified in fulladleModuleConfig block - // we need library modules to specify the app apk to test against, even if it's a dummy one - check(fulladleModuleExtension.debugApk.isPresent && fulladleModuleExtension.debugApk.orNull != null) { - "Library module ${project.path} did not specify a debug apk. Library modules do not " + - "generate a debug apk and one needs to be specified in the fulladleModuleConfig block\n" + - "This is a required parameter in FTL which remains unused for library modules under test, " + - "and you can use a dummy apk here" - } - flankGradleExtension.debugApk.set(rootProject.provider { fulladleModuleExtension.debugApk.get() }) - } - } else { - // Otherwise, let's just add it to the list. - if (project.isAndroidAppModule) { - yml.appendLine("- app: ${this@app.outputFile}") - } else if (project.isAndroidLibraryModule) { - // app apk is not required for library modules so only use if it's explicitly specified - if (fulladleModuleExtension.debugApk.orNull != null) { - yml.appendLine("- app: ${fulladleModuleExtension.debugApk.get()}") - } - } - } + for (variantInfo in fulladleModuleExtension.variantApks) { + if (addedTestsForModule) break - // If the instrumentation apk isn't yet set, let's use this one. - if (!flankGradleExtension.instrumentationApk.isPresent) { - flankGradleExtension.instrumentationApk.set(rootProject.provider { this@test.outputFile.absolutePath }) - } else { - // Otherwise, let's just add it to the list. - if (yml.isBlank()) { - // The first item in the list needs to start with a ` - `. - yml.appendLine("- test: ${this@test.outputFile}") - } else { - yml.appendLine(" test: ${this@test.outputFile}") - } - } + if (!variantInfo.isExpectedVariantInModule(fulladleModuleExtension)) continue - if (yml.isEmpty()) { - // this is the root module - // should not be added as additional test apk - overrideRootLevelConfigs(flankGradleExtension, fulladleModuleExtension) - } else { - yml.appendProperty(fulladleModuleExtension.maxTestShards, " max-test-shards") - yml.appendMapProperty( - fulladleModuleExtension.clientDetails, - " client-details", - ) { appendLine(" ${it.key}: ${it.value}") } - yml.appendMapProperty( - fulladleModuleExtension.environmentVariables, - " environment-variables", - ) { appendLine(" ${it.key}: ${it.value}") } - flankGradleExtension.additionalTestApks.add(yml.toString()) - } - addedTestsForModule = true - } + // Check ABI filter against the extension + if (flankGradleExtension.abi.isPresent && variantInfo.abiName != null && variantInfo.abiName != flankGradleExtension.abi.get()) continue + if (flankGradleExtension.abi.isPresent && variantInfo.abiName == null) { + // No ABI filter on this output - it's a match (universal) + } + + val yml = StringBuilder() + // If the debugApk isn't yet set, let's use this one. + if (!flankGradleExtension.debugApk.isPresent) { + if (project.isAndroidAppModule && variantInfo.appApkPath != null) { + // app modules produce app apks that we can consume + flankGradleExtension.debugApk.set(rootProject.provider { variantInfo.appApkPath }) + } else if (project.isAndroidLibraryModule) { + // library modules do not produce an app apk and we'll use the one specified in fulladleModuleConfig block + // we need library modules to specify the app apk to test against, even if it's a dummy one + check(fulladleModuleExtension.debugApk.isPresent && fulladleModuleExtension.debugApk.orNull != null) { + "Library module ${project.path} did not specify a debug apk. Library modules do not " + + "generate a debug apk and one needs to be specified in the fulladleModuleConfig block\n" + + "This is a required parameter in FTL which remains unused for library modules under test, " + + "and you can use a dummy apk here" } + flankGradleExtension.debugApk.set(rootProject.provider { fulladleModuleExtension.debugApk.get() }) + } + } else { + // Otherwise, let's just add it to the list. + if (project.isAndroidAppModule && variantInfo.appApkPath != null) { + yml.appendLine("- app: ${variantInfo.appApkPath}") + } else if (project.isAndroidLibraryModule) { + // app apk is not required for library modules so only use if it's explicitly specified + if (fulladleModuleExtension.debugApk.orNull != null) { + yml.appendLine("- app: ${fulladleModuleExtension.debugApk.get()}") + } + } + } + + // If the instrumentation apk isn't yet set, let's use this one. + if (!flankGradleExtension.instrumentationApk.isPresent) { + flankGradleExtension.instrumentationApk.set(rootProject.provider { variantInfo.testApkPath }) + } else { + // Otherwise, let's just add it to the list. + if (yml.isBlank()) { + // The first item in the list needs to start with a ` - `. + yml.appendLine("- test: ${variantInfo.testApkPath}") + } else { + yml.appendLine(" test: ${variantInfo.testApkPath}") + } + } + + if (yml.isEmpty()) { + // this is the root module + // should not be added as additional test apk + overrideRootLevelConfigs(flankGradleExtension, fulladleModuleExtension) + } else { + yml.appendProperty(fulladleModuleExtension.maxTestShards, " max-test-shards") + yml.appendMapProperty( + fulladleModuleExtension.clientDetails, + " client-details", + ) { appendLine(" ${it.key}: ${it.value}") } + yml.appendMapProperty( + fulladleModuleExtension.environmentVariables, + " environment-variables", + ) { appendLine(" ${it.key}: ${it.value}") } + flankGradleExtension.additionalTestApks.add(yml.toString()) } + addedTestsForModule = true } } @@ -180,16 +266,11 @@ val Project.hasAndroidTest: Boolean if (!fulladleModuleExtension.enabled.get()) { return false } - val testedExtension = extensions.findByType(TestedExtension::class.java) ?: return false - var testsFound = true - testedExtension.testVariants.configureEach testVariant@{ - if (!file("$projectDir/src/androidTest").exists()) { - println("Ignoring $name test variant in $path: No tests in $projectDir/src/androidTest") - testsFound = false - } - return@testVariant + if (!file("$projectDir/src/androidTest").exists()) { + println("Ignoring test variants in $path: No tests in $projectDir/src/androidTest") + return false } - return testsFound + return true } fun overrideRootLevelConfigs( diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/RequiredDeviceKeyMissingException.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/RequiredDeviceKeyMissingException.kt index 31427c11..7682930f 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/RequiredDeviceKeyMissingException.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/RequiredDeviceKeyMissingException.kt @@ -1,3 +1,5 @@ package com.osacky.flank.gradle -data class RequiredDeviceKeyMissingException(val key: String) : Exception("Device should have '$key' key set to a value.") +data class RequiredDeviceKeyMissingException( + val key: String, +) : Exception("Device should have '$key' key set to a value.") diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/SanityConfigValidation.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/SanityConfigValidation.kt index 26d91f4a..b26bcd5f 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/SanityConfigValidation.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/SanityConfigValidation.kt @@ -6,16 +6,22 @@ import java.lang.IllegalArgumentException fun checkIfSanityAndValidateConfigs(config: FladleConfig) = when (config) { - is FlankGradleExtension -> + is FlankGradleExtension -> { config.checkAndValidateConfig { option, name -> "Incorrect [$name] configuration. [$option] can't be used together with sanityRobo." } - is FladleConfigImpl -> + } + + is FladleConfigImpl -> { config.checkAndValidateConfig(config.name) { option, name -> "Incorrect [$name] configuration. [$option] can't be used together with sanityRobo. " + "To configure sanityRobo, add clearPropertiesForSanityRobo() to the [$name] configuration" } - else -> throw IllegalArgumentException("Unexpected configuration when validating parameters. Did not expect: $config.") + } + + else -> { + throw IllegalArgumentException("Unexpected configuration when validating parameters. Did not expect: $config.") + } } private fun FladleConfig.checkAndValidateConfig( @@ -35,5 +41,5 @@ private fun FladleConfig.checkAndValidateConfig( val Property.hasValue get() = orNull.isNullOrBlank().not() -private val ListProperty.hasValue +private val ListProperty.hasValue get() = getOrElse(emptyList()).isNotEmpty() diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/VariantApkInfo.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/VariantApkInfo.kt new file mode 100644 index 00000000..b69db0fa --- /dev/null +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/VariantApkInfo.kt @@ -0,0 +1,11 @@ +package com.osacky.flank.gradle + +data class VariantApkInfo( + val variantName: String, + val appApkPath: String?, + val testApkPath: String, + val abiName: String?, +) { + fun isExpectedVariantInModule(config: FulladleModuleExtension): Boolean = + !config.variant.isPresent || (config.variant.isPresent && variantName.contains(config.variant.get())) +} diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/Variants.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/Variants.kt index 630ed2ab..df732443 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/Variants.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/Variants.kt @@ -1,33 +1,34 @@ package com.osacky.flank.gradle -import com.android.build.VariantOutput -import com.android.build.gradle.api.BaseVariant -import com.android.build.gradle.api.BaseVariantOutput +import com.android.build.api.variant.FilterConfiguration +import com.android.build.api.variant.Variant +import com.android.build.api.variant.VariantOutput /** - * Returns true if this [BaseVariant] matches the variant specified in the [config]. + * Returns true if this [Variant] matches the variant specified in the [config]. * * If no variant is specified, all variants are considered a match. */ -fun BaseVariant.isExpectedVariant(config: FladleConfig) = +fun Variant.isExpectedVariant(config: FladleConfig) = !config.variant.isPresent || (config.variant.isPresent && config.variant.get() == this.name) /** - * Returns true if this [BaseVariantOutput] matches the ABI specified in the [config]. + * Returns true if this [VariantOutput] matches the ABI specified in the [config]. * - * If the config does not specify an ABI, or if the config specifies an ABI but the [BaseVariantOutput] + * If the config does not specify an ABI, or if the config specifies an ABI but the [VariantOutput] * is not filtered by ABI, it is considered a match. */ -fun BaseVariantOutput.isExpectedAbiOutput(config: FladleConfig): Boolean { +fun VariantOutput.isExpectedAbiOutput(config: FladleConfig): Boolean { + val abiFilters = filters.filter { it.filterType == FilterConfiguration.FilterType.ABI } return !config.abi.isPresent || - !filterTypes.contains(VariantOutput.FilterType.ABI.name) || - filters.single { it.filterType == VariantOutput.FilterType.ABI.name }.identifier == config.abi.get() + abiFilters.isEmpty() || + abiFilters.any { it.identifier == config.abi.get() } } /** - * Returns true if this [BaseVariant] matches the variant specified in the [config]. + * Returns true if this [Variant] matches the variant specified in the [config]. * * If no variant is specified, all variants are considered a match. */ -fun BaseVariant.isExpectedVariantInModule(config: FulladleModuleExtension) = +fun Variant.isExpectedVariantInModule(config: FulladleModuleExtension) = !config.variant.isPresent || (config.variant.isPresent && this.name.contains(config.variant.get())) diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt index 12373cbb..1d3b09e6 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt @@ -38,21 +38,18 @@ open class YamlConfigWriterTask @get:Input val additionalTestApks: ListProperty = - objects.listProperty(String::class.java) + objects + .listProperty(String::class.java) .convention(config.additionalTestApks) @OutputFile val fladleConfigFile: Provider = fladleDir.map { it.file("flank.yml") } @Internal - override fun getDescription(): String { - return "Writes a flank.yml file based on the current FlankGradleExtension configuration." - } + override fun getDescription(): String = "Writes a flank.yml file based on the current FlankGradleExtension configuration." @Internal - override fun getGroup(): String { - return FladlePluginDelegate.TASK_GROUP - } + override fun getGroup(): String = FladlePluginDelegate.TASK_GROUP @TaskAction fun writeFile() { diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlExtensions.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlExtensions.kt index 75d41a73..a80543bc 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlExtensions.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlExtensions.kt @@ -4,14 +4,14 @@ import org.gradle.api.provider.ListProperty import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Property -fun StringBuilder.appendProperty( +fun StringBuilder.appendProperty( prop: Property, name: String, ) { if (prop.isPresent) appendLine(" $name: ${prop.get()}") } -fun StringBuilder.appendMapProperty( +fun StringBuilder.appendMapProperty( prop: MapProperty, name: String, custom: StringBuilder.(Map.Entry) -> Unit, @@ -22,7 +22,7 @@ fun StringBuilder.appendMapProperty( } } -fun StringBuilder.appendListProperty( +fun StringBuilder.appendListProperty( prop: ListProperty, name: String, custom: StringBuilder.(T) -> Unit, @@ -35,15 +35,16 @@ fun StringBuilder.appendListProperty( fun StringBuilder.appendAdditionalProperty(property: Property) { if (property.isPresent) { - property.get() + property + .get() .split("\n") .map { " $it" } .forEach { appendLine(it) } } } -val ListProperty.isPresentAndNotEmpty +val ListProperty.isPresentAndNotEmpty get() = isPresent && get().isNotEmpty() -val MapProperty.isPresentAndNotEmpty +val MapProperty.isPresentAndNotEmpty get() = isPresent && get().isNotEmpty() diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt index 2262148f..d2083f64 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt @@ -126,7 +126,8 @@ internal class YamlWriter { } else { appendListProperty(config.roboDirectives, name = "robo-directives") { val value = - it.getOrElse(2) { "" } + it + .getOrElse(2) { "" } .let { stringValue -> if (stringValue.isBlank()) "\"\"" else stringValue } appendLine(" ${it[0]}:${it[1]}: $value") } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/SinceFlank.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/SinceFlank.kt index 142b9713..f35e6222 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/SinceFlank.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/SinceFlank.kt @@ -1,3 +1,6 @@ package com.osacky.flank.gradle.validation -annotation class SinceFlank(val version: String, val hasDefaultValue: Boolean = false) +annotation class SinceFlank( + val version: String, + val hasDefaultValue: Boolean = false, +) diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/ValidateOptions.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/ValidateOptions.kt index 9fead49d..0a48b133 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/ValidateOptions.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/ValidateOptions.kt @@ -11,7 +11,8 @@ fun validateOptionsUsed( // if using snapshot version default to the latest known version of flank for validation checks val configFlankVersion = if (flank.toLowerCase().endsWith("snapshot")) FLANK_VERSION.toVersion() else flank.toVersion() - config.getPresentProperties() + config + .getPresentProperties() .mapNotNull { property -> properties[property.name]?.let { property to it } } .forEach { (property, version) -> if (version > configFlankVersion) { @@ -25,11 +26,18 @@ fun validateOptionsUsed( private fun String.toVersion() = VersionNumber.parse(this) private val properties = - FladleConfig::class.memberProperties + FladleConfig::class + .memberProperties .asSequence() .map { it to it.getter.annotations } // we also need to exclude properties with default values to preserve backward compatibility // to be fixed .filter { it.second.any { annotation -> annotation is SinceFlank && !annotation.hasDefaultValue } } - .map { it.first.name to it.second.filterIsInstance().first().version.toVersion() } - .toMap() + .map { + it.first.name to + it.second + .filterIsInstance() + .first() + .version + .toVersion() + }.toMap() diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/VersionNumber.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/VersionNumber.kt index d17b4b3e..90d5d9cb 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/VersionNumber.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/validation/VersionNumber.kt @@ -40,13 +40,13 @@ class VersionNumber private constructor( if (patch != other.patch) { return patch - other.patch } - return qualifier.orEmpty().lowercase() + return qualifier + .orEmpty() + .lowercase() .compareTo(other.qualifier.orEmpty().lowercase()) } - override fun equals(other: Any?): Boolean { - return other is VersionNumber && compareTo(other) == 0 - } + override fun equals(other: Any?): Boolean = other is VersionNumber && compareTo(other) == 0 override fun hashCode(): Int { var result = major @@ -57,9 +57,7 @@ class VersionNumber private constructor( return result } - override fun toString(): String { - return scheme.format(this) - } + override fun toString(): String = scheme.format(this) /** * Returns the version number scheme. @@ -70,7 +68,9 @@ class VersionNumber private constructor( fun format(versionNumber: VersionNumber): String } - private abstract class AbstractScheme protected constructor(val depth: Int) : Scheme { + private abstract class AbstractScheme protected constructor( + val depth: Int, + ) : Scheme { override fun parse(versionString: String): VersionNumber { if (versionString.isEmpty()) { return UNKNOWN @@ -109,16 +109,15 @@ class VersionNumber private constructor( return UNKNOWN } - private class Scanner(val str: String) { + private class Scanner( + val str: String, + ) { var pos: Int = 0 - fun hasDigit(): Boolean { - return pos < str.length && Character.isDigit(str.get(pos)) - } + fun hasDigit(): Boolean = pos < str.length && Character.isDigit(str.get(pos)) - fun isSeparatorAndDigit(vararg separators: Char): Boolean { - return pos < str.length - 1 && oneOf(*separators) && Character.isDigit(str.get(pos + 1)) - } + fun isSeparatorAndDigit(vararg separators: Char): Boolean = + pos < str.length - 1 && oneOf(*separators) && Character.isDigit(str.get(pos + 1)) fun oneOf(vararg separators: Char): Boolean { val current = str.get(pos) @@ -148,22 +147,19 @@ class VersionNumber private constructor( pos++ } - fun remainder(): String? { - return if (pos == str.length) null else str.substring(pos) - } + fun remainder(): String? = if (pos == str.length) null else str.substring(pos) } } private class DefaultScheme : AbstractScheme(3) { - override fun format(versionNumber: VersionNumber): String { - return String.format( + override fun format(versionNumber: VersionNumber): String = + String.format( VERSION_TEMPLATE, versionNumber.major, versionNumber.minor, versionNumber.micro, if (versionNumber.qualifier == null) "" else "-" + versionNumber.qualifier, ) - } companion object { private const val VERSION_TEMPLATE = "%d.%d.%d%s" @@ -178,8 +174,8 @@ class VersionNumber private constructor( fun version( major: Int, minor: Int = 0, - ): VersionNumber { - return VersionNumber( + ): VersionNumber = + VersionNumber( major = major, minor = minor, micro = 0, @@ -187,10 +183,7 @@ class VersionNumber private constructor( qualifier = null, scheme = DEFAULT_SCHEME, ) - } - fun parse(versionString: String): VersionNumber { - return DEFAULT_SCHEME.parse(versionString) - } + fun parse(versionString: String): VersionNumber = DEFAULT_SCHEME.parse(versionString) } } diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/MultipleConfigsTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/MultipleConfigsTest.kt index 8b7a732f..dcb28cd3 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/MultipleConfigsTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/MultipleConfigsTest.kt @@ -38,7 +38,8 @@ class MultipleConfigsTest { testProjectRoot.newFile("flank-gradle-service.json").writeText("{}") val result = - GradleRunner.create() + GradleRunner + .create() .withPluginClasspath() .withArguments("writeConfigPropsOrange", "--stacktrace") .forwardOutput() @@ -76,7 +77,8 @@ class MultipleConfigsTest { ) val regularConfig = - GradleRunner.create() + GradleRunner + .create() .withPluginClasspath() .withArguments("writeConfigProps") .forwardOutput() diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/AutoConfigureFladleTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/AutoConfigureFladleTest.kt index a4c870ae..848eb540 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/AutoConfigureFladleTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/AutoConfigureFladleTest.kt @@ -39,7 +39,8 @@ class AutoConfigureFladleTest { testProjectRoot.setupFixture(fixtureName) val result = - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .withArguments("assembleDebug", "assembleDebugAndroidTest", "printYml", "--stacktrace") diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt index 2f9b5582..83cbf547 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt @@ -115,7 +115,7 @@ class ConfigurationCacheTest { settings.writeText( """ plugins { - id 'com.gradle.enterprise' version '3.7' + id 'com.gradle.develocity' version '4.3' } """.trimIndent(), ) @@ -131,11 +131,11 @@ class ConfigurationCacheTest { assertThat(secondResult.output).contains("Reusing configuration cache.") } - private fun configCachingRunner(arg: String): GradleRunner { - return GradleRunner.create() + private fun configCachingRunner(arg: String): GradleRunner = + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .forwardOutput() .withArguments(arg, "--configuration-cache") - } } diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FlankGradlePluginIntegrationTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FlankGradlePluginIntegrationTest.kt index 1a1ff22d..d95df33f 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FlankGradlePluginIntegrationTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FlankGradlePluginIntegrationTest.kt @@ -11,8 +11,8 @@ class FlankGradlePluginIntegrationTest { @get:Rule var testProjectRoot = TemporaryFolder() - val minSupportGradleVersion = "7.3" - val oldVersion = "7.2" + val minSupportGradleVersion = "9.1.0" + val oldVersion = "9.0.0" fun writeBuildGradle(build: String) { testProjectRoot.writeBuildDotGradle(build) @@ -27,16 +27,17 @@ class FlankGradlePluginIntegrationTest { """.trimMargin(), ) val result = - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .withGradleVersion(oldVersion) .buildAndFail() - assertThat(result.output).contains("Fladle requires at minimum version Gradle 7.3. Detected version Gradle 7.2") + assertThat(result.output).contains("Fladle requires at minimum version Gradle 9.1. Detected version Gradle 9.0.0") } @Test - fun testGradleEightOh() { + fun testGradleNineOne() { writeBuildGradle( """plugins { | id "com.osacky.fladle" @@ -44,10 +45,11 @@ class FlankGradlePluginIntegrationTest { """.trimMargin(), ) val result = - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() - .withGradleVersion("8.0") + .withGradleVersion("9.1.0") .build() assertThat(result.output).contains("SUCCESS") @@ -61,7 +63,8 @@ class FlankGradlePluginIntegrationTest { |} """.trimMargin(), ) - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .withGradleVersion(minSupportGradleVersion) @@ -82,7 +85,8 @@ class FlankGradlePluginIntegrationTest { |} """.trimMargin(), ) - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .withGradleVersion(minSupportGradleVersion) @@ -103,7 +107,8 @@ class FlankGradlePluginIntegrationTest { """.trimMargin(), ) val result = - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .withGradleVersion(minSupportGradleVersion) @@ -131,7 +136,8 @@ class FlankGradlePluginIntegrationTest { ) testProjectRoot.newFile("foo").writeText("{}") val result = - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .withGradleVersion(minSupportGradleVersion) @@ -155,7 +161,8 @@ class FlankGradlePluginIntegrationTest { ) testProjectRoot.newFile("foo").writeText("{}") val result = - GradleRunner.create() + GradleRunner + .create() .withProjectDir(testProjectRoot.root) .withPluginClasspath() .withGradleVersion(minSupportGradleVersion) @@ -182,7 +189,8 @@ class FlankGradlePluginIntegrationTest { ) testProjectRoot.writeEmptyServiceCredential() val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withGradleVersion(minSupportGradleVersion) .withArguments("printYml") .buildAndFail() @@ -209,7 +217,8 @@ class FlankGradlePluginIntegrationTest { ) testProjectRoot.writeEmptyServiceCredential() val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withGradleVersion(minSupportGradleVersion) .withArguments("printYml") .buildAndFail() @@ -236,7 +245,8 @@ class FlankGradlePluginIntegrationTest { ) testProjectRoot.writeEmptyServiceCredential() val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withGradleVersion(minSupportGradleVersion) .withArguments("printYml") .buildAndFail() @@ -264,7 +274,8 @@ class FlankGradlePluginIntegrationTest { ) testProjectRoot.writeEmptyServiceCredential() val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withGradleVersion(minSupportGradleVersion) .withArguments("printYml") .buildAndFail() @@ -291,8 +302,9 @@ class FlankGradlePluginIntegrationTest { ) testProjectRoot.writeEmptyServiceCredential() val result = - testProjectRoot.gradleRunner() - .withGradleVersion("8.0") + testProjectRoot + .gradleRunner() + .withGradleVersion("9.1.0") .withArguments("printYmlFooConfig") .build() assertThat(result.task(":printYmlFooConfig")!!.outcome).isEqualTo(TaskOutcome.SUCCESS) diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt index 6706b2e2..7d2a3ecf 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt @@ -10,7 +10,7 @@ class FulladlePluginIntegrationTest { @get:Rule var testProjectRoot = TemporaryFolder() - val agpDependency: String = "com.android.tools.build:gradle:4.2.1" + val agpDependency: String = "com.android.tools.build:gradle:9.0.1" fun writeBuildGradle(build: String) { val file = testProjectRoot.newFile("build.gradle") @@ -26,7 +26,8 @@ class FulladlePluginIntegrationTest { """.trimMargin(), ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments("help") .build() assertThat(result.output).contains("SUCCESS") @@ -88,7 +89,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() @@ -189,7 +191,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() @@ -271,7 +274,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() @@ -395,7 +399,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() @@ -446,10 +451,9 @@ class FulladlePluginIntegrationTest { testProjectRoot.newFile("settings.gradle").writeText( """ include '$appFixture' - include '$libraryFixture' include '$flavourProject' include '$flavourLibrary' - + dependencyResolutionManagement { repositories { mavenCentral() @@ -468,17 +472,17 @@ class FulladlePluginIntegrationTest { repositories { google() } - + dependencies { classpath '$agpDependency' } } - + plugins { id "com.osacky.fulladle" } - - + + fladle { serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") } @@ -486,7 +490,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() @@ -576,7 +581,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() assertThat(result.output).doesNotContain("max-test-shards: 4") @@ -632,7 +638,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .buildAndFail() @@ -708,7 +715,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .buildAndFail() @@ -797,7 +805,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() @@ -881,7 +890,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() @@ -967,7 +977,8 @@ class FulladlePluginIntegrationTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments(":printYml") .build() diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/TestFixtures.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/TestFixtures.kt index e9f83355..6ad7a89c 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/TestFixtures.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/TestFixtures.kt @@ -5,7 +5,11 @@ import org.junit.rules.TemporaryFolder import java.io.File fun TemporaryFolder.setupFixture(fixtureName: String) { - File(this::class.java.classLoader.getResource(fixtureName)!!.file).copyRecursively(newFile(fixtureName), true) + File( + this::class.java.classLoader + .getResource(fixtureName)!! + .file, + ).copyRecursively(newFile(fixtureName), true) } internal fun TemporaryFolder.writeBuildDotGradle(buildScript: String) = @@ -13,7 +17,8 @@ internal fun TemporaryFolder.writeBuildDotGradle(buildScript: String) = .writeText(buildScript) fun TemporaryFolder.gradleRunner() = - GradleRunner.create() + GradleRunner + .create() .withPluginClasspath() .forwardOutput() .withProjectDir(root) diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/VariantTests.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/VariantTests.kt index c0f9a0c5..29f40468 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/VariantTests.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/VariantTests.kt @@ -67,15 +67,13 @@ class VariantTests { assertThat(result.output).doesNotContain(":assembleVanillaRelease") assertThat(result.output).doesNotContain(":assembleChocolate") - /** - * See #60 https://github.com/runningcode/fladle/issues/60 - testProjectRoot.writeEmptyServiceCredential() - val resultPrint = testProjectRoot.gradleRunner() - .withArguments("printYmlVanilla") - .build() - assertThat(resultPrint.output).contains("build/outputs/apk/vanilla/debug/chocovanilla-vanilla-debug.apk") - assertThat(resultPrint.output).contains("build/outputs/apk/androidTest/vanilla/debug/chocovanilla-vanilla-debug-androidTest.apk") - **/ + // See #60 https://github.com/runningcode/fladle/issues/60 + // testProjectRoot.writeEmptyServiceCredential() + // val resultPrint = testProjectRoot.gradleRunner() + // .withArguments("printYmlVanilla") + // .build() + // assertThat(resultPrint.output).contains("build/outputs/apk/vanilla/debug/chocovanilla-vanilla-debug.apk") + // assertThat(resultPrint.output).contains("build/outputs/apk/androidTest/vanilla/debug/chocovanilla-vanilla-debug-androidTest.apk") } @Test @@ -106,6 +104,8 @@ class VariantTests { |include ':android-project' """.trimMargin(), ) + testProjectRoot.newFile("local.properties").writeText("sdk.dir=${androidHome()}\n") + testProjectRoot.newFile("gradle.properties").writeText("android.useAndroidX=true") testProjectRoot.setupFixture("android-project") val flavors = if (withFlavors) { @@ -191,7 +191,8 @@ class VariantTests { if (dryRun) { arguments.add("--dry-run") } - return testProjectRoot.gradleRunner() + return testProjectRoot + .gradleRunner() .withArguments(arguments) .build() } diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateExclusionsTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateExclusionsTest.kt index 0b5d05b4..4ffb9517 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateExclusionsTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateExclusionsTest.kt @@ -32,7 +32,8 @@ class ValidateExclusionsTest { ) val result = - testProjectRoot.gradleRunner() + testProjectRoot + .gradleRunner() .withArguments("printYml") .buildAndFail() diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateOptionsTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateOptionsTest.kt index 8ef828cd..0800d233 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateOptionsTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/validation/ValidateOptionsTest.kt @@ -16,7 +16,12 @@ class ValidateOptionsTest { @get:Rule var testProjectRoot = TemporaryFolder() - private val objects = ProjectBuilder.builder().withName("project").build().objects + private val objects = + ProjectBuilder + .builder() + .withName("project") + .build() + .objects private lateinit var config: FladleConfig @Before diff --git a/gradle.properties b/gradle.properties index b2732dd2..b9267d76 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,8 +15,6 @@ org.gradle.jvmargs=-Xmx1536m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=false kotlin.code.style=official diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8a6ca392..1079e105 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,13 +5,12 @@ foojay = { id = "org.gradle.toolchains.foojay-resolver-convention", version = "1 ben-manes-versions = { id = "com.github.ben-manes.versions", version = "0.51.0" } -kotlinter = { id = "org.jmailen.kotlinter", version = "4.0.0" } +kotlinter = { id = "org.jmailen.kotlinter", version = "5.4.2" } gradle-plugin-publish = {id = "com.gradle.plugin-publish", version = "2.0.0" } vanniktech-publish = { id = "com.vanniktech.maven.publish", version = "0.35.0" } -kgp = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin"} agp = { id = "com.android.application", version.ref = "agp-version"} [versions] @@ -30,8 +29,7 @@ androidx-test-rules = "1.7.0" junit-version = "4.13.2" -kotlin = "2.2.21" -agp-version = "7.4.2" +agp-version = "9.0.1" flank-version = "23.10.1" [libraries] @@ -54,8 +52,6 @@ flank = { module = "com.github.flank:flank", version.ref = "flank-version" } junit = { group = "junit", name = "junit", version.ref = "junit-version" } -kotlin-stdlib-jdk7 = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk7", version.ref = "kotlin" } - gradle-enterprise = { module = "com.gradle:develocity-gradle-plugin", version = "3.19.2" } truth = "com.google.truth:truth:1.4.5" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da4..2e111328 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/sample-android-library/build.gradle.kts b/sample-android-library/build.gradle.kts index 8cf64b3f..63a998cb 100644 --- a/sample-android-library/build.gradle.kts +++ b/sample-android-library/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id("com.android.library") - kotlin("android") } fulladleModuleConfig { diff --git a/sample-flavors-kotlin/build.gradle.kts b/sample-flavors-kotlin/build.gradle.kts index ff6a537a..2b131a90 100644 --- a/sample-flavors-kotlin/build.gradle.kts +++ b/sample-flavors-kotlin/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id ("com.android.application") - kotlin("android") id ("com.osacky.fladle") } diff --git a/sample-kotlin/build.gradle.kts b/sample-kotlin/build.gradle.kts index 4c8a977d..5d729cec 100644 --- a/sample-kotlin/build.gradle.kts +++ b/sample-kotlin/build.gradle.kts @@ -1,6 +1,5 @@ plugins { id ("com.android.application") - kotlin("android") id ("com.osacky.fladle") } diff --git a/sample/build.gradle b/sample/build.gradle index f39d1a7c..71b98804 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -1,5 +1,4 @@ apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' apply plugin: 'com.osacky.fladle' android { From 4a086dc42c43195406c6519d7ac7a161cdb76cea Mon Sep 17 00:00:00 2001 From: Matthew Runo <74583+inktomi@users.noreply.github.com> Date: Mon, 2 Mar 2026 14:30:49 -0800 Subject: [PATCH 3/6] Lint fixes - converting runAndFail() functions from block body to expression body syntax --- .../com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt | 4 +--- .../com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt | 4 +--- .../com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt | 4 +--- .../com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt | 4 +--- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/sample-android-library/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt b/sample-android-library/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt index ab896993..0b6fe002 100644 --- a/sample-android-library/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt +++ b/sample-android-library/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt @@ -12,7 +12,5 @@ class ExampleInstrumentedTest { } @Test - fun runAndFail() { - throw RuntimeException("Test failed") - } + fun runAndFail(): Unit = throw RuntimeException("Test failed") } diff --git a/sample-flavors-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt b/sample-flavors-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt index 5d25f62c..efa6aae8 100644 --- a/sample-flavors-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt +++ b/sample-flavors-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt @@ -23,7 +23,5 @@ class ExampleInstrumentedTest { } @Test - fun runAndFail() { - throw RuntimeException("Test failed") - } + fun runAndFail(): Unit = throw RuntimeException("Test failed") } diff --git a/sample-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt b/sample-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt index 57d1a72f..cec17543 100644 --- a/sample-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt +++ b/sample-kotlin/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt @@ -22,7 +22,5 @@ class ExampleInstrumentedTest { } @Test - fun runAndFail() { - throw RuntimeException("Test failed") - } + fun runAndFail(): Unit = throw RuntimeException("Test failed") } diff --git a/sample/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt b/sample/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt index 2587b288..f71566f5 100644 --- a/sample/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt +++ b/sample/src/androidTest/java/com/osacky/flank/gradle/sample/ExampleInstrumentedTest.kt @@ -22,7 +22,5 @@ class ExampleInstrumentedTest { } @Test - fun runAndFail() { - throw RuntimeException("Test failed") - } + fun runAndFail(): Unit = throw RuntimeException("Test failed") } From 1ce12f207e79d8542ab0422fbce5c199a30105ca Mon Sep 17 00:00:00 2001 From: Matthew Runo <74583+inktomi@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:03:26 -0800 Subject: [PATCH 4/6] Refactored for support of gradle config cache, in particular introduced new FulladleSettingsPlugin to manage the fulladle multi-module setup and refactored to use it --- fladle-plugin/build.gradle.kts | 14 + .../flank/gradle/FladlePluginDelegate.kt | 123 +++--- .../com/osacky/flank/gradle/FlankJavaExec.kt | 6 +- .../gradle/FulladleModuleMetadataTask.kt | 138 +++++++ .../flank/gradle/FulladleModulePlugin.kt | 154 ++++++++ .../com/osacky/flank/gradle/FulladlePlugin.kt | 123 ++++-- .../flank/gradle/FulladleSettingsPlugin.kt | 14 + .../flank/gradle/ModuleMetadataParser.kt | 255 +++++++++++++ .../flank/gradle/YamlConfigWriterTask.kt | 75 +++- .../com/osacky/flank/gradle/YamlWriter.kt | 5 +- .../flank/gradle/ModuleMetadataParserTest.kt | 358 ++++++++++++++++++ .../integration/FulladleSettingsPluginTest.kt | 323 ++++++++++++++++ 12 files changed, 1464 insertions(+), 124 deletions(-) create mode 100644 fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleMetadataTask.kt create mode 100644 fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModulePlugin.kt create mode 100644 fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleSettingsPlugin.kt create mode 100644 fladle-plugin/src/main/java/com/osacky/flank/gradle/ModuleMetadataParser.kt create mode 100644 fladle-plugin/src/test/java/com/osacky/flank/gradle/ModuleMetadataParserTest.kt create mode 100644 fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladleSettingsPluginTest.kt diff --git a/fladle-plugin/build.gradle.kts b/fladle-plugin/build.gradle.kts index 29d4e847..319f53a1 100644 --- a/fladle-plugin/build.gradle.kts +++ b/fladle-plugin/build.gradle.kts @@ -53,6 +53,20 @@ gradlePlugin { implementationClass = "com.osacky.flank.gradle.FulladlePlugin" tags.set(listOf("flank", "testing", "android", "fladle")) } + create("fulladleSettings") { + id = "com.osacky.fulladle.settings" + displayName = "Fulladle Settings" + description = project.description + implementationClass = "com.osacky.flank.gradle.FulladleSettingsPlugin" + tags.set(listOf("flank", "testing", "android", "fladle")) + } + create("fulladleModule") { + id = "com.osacky.fulladle.module" + displayName = "Fulladle Module" + description = project.description + implementationClass = "com.osacky.flank.gradle.FulladleModulePlugin" + tags.set(listOf("flank", "testing", "android", "fladle")) + } } } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt index 249a2414..9a0b5897 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt @@ -19,22 +19,19 @@ class FladlePluginDelegate { checkMinimumGradleVersion() // Create Configuration to store flank dependency - target.configurations.create(FLADLE_CONFIG) + val fladleConfig = target.configurations.create(FLADLE_CONFIG) val extension = target.extensions.create("fladle", target.objects) target.tasks.register("flankAuth", FlankJavaExec::class.java) { doFirst { - target.layout.fladleDir - .get() - .asFile - .mkdirs() + workingDir.mkdirs() } - classpath = project.fladleConfig + classpath = fladleConfig args = listOf("auth", "login") } - configureTasks(target, extension) + configureTasks(target, extension, fladleConfig) } private fun checkMinimumGradleVersion() { @@ -46,12 +43,18 @@ class FladlePluginDelegate { private fun configureTasks( project: Project, base: FlankGradleExtension, + fladleConfig: Configuration, ) { base.flankVersion.finalizeValueOnRead() base.flankCoordinates.finalizeValueOnRead() base.serviceAccountCredentials.finalizeValueOnRead() - // Register onVariants callbacks before afterEvaluate for APK path detection + // Use defaultDependencies to lazily add the Flank dependency + fladleConfig.defaultDependencies { + add(project.dependencies.create("${base.flankCoordinates.get()}:${base.flankVersion.get()}")) + } + + // Register onVariants callbacks before task registration for APK path detection project.pluginManager.withPlugin("com.android.application") { if (!base.debugApk.isPresent || !base.instrumentationApk.isPresent) { findDebugAndInstrumentationApk(project, base) @@ -59,10 +62,6 @@ class FladlePluginDelegate { } project.afterEvaluate { - // Add Flank dependency to Fladle Configuration - // Must be done afterEvaluate otherwise extension values will not be set. - project.dependencies.add(FLADLE_CONFIG, "${base.flankCoordinates.get()}:${base.flankVersion.get()}") - tasks.apply { createTasksForConfig(base, base, project, "") @@ -79,17 +78,20 @@ class FladlePluginDelegate { project: Project, name: String, ) { + val fladleConfig = project.configurations.getByName(FLADLE_CONFIG) val configName = name.toLowerCase() // we want to use default dir only if user did not set own `localResultsDir` val useDefaultDir = config.localResultsDir.isPresent.not() + val flankVersionProvider = base.flankVersion + val validateFladle = register("validateFladleConfig$name") { description = "Perform validation actions" group = TASK_GROUP doLast { checkIfSanityAndValidateConfigs(config) - validateOptionsUsed(config = config, flank = base.flankVersion.get()) + validateOptionsUsed(config = config, flank = flankVersionProvider.get()) checkForExclusionUsage(config) } } @@ -102,78 +104,57 @@ class FladlePluginDelegate { description = "Print the flank.yml file to the console." group = TASK_GROUP dependsOn(writeConfigProps) + val configFile = writeConfigProps.flatMap { it.fladleConfigFile } + inputs.file(configFile) doLast { - println( - writeConfigProps - .get() - .fladleConfigFile - .get() - .asFile - .readText(), - ) + println(configFile.get().asFile.readText()) } } register("flankDoctor$name", FlankJavaExec::class.java) { if (useDefaultDir) setUpWorkingDir(configName) description = "Finds problems with the current configuration." - classpath = project.fladleConfig + classpath = fladleConfig + val configFilePath = writeConfigProps.flatMap { it.fladleConfigFile }.map { it.asFile.absolutePath } args = listOf( "firebase", "test", "android", "doctor", - "-c", - writeConfigProps - .get() - .fladleConfigFile - .get() - .asFile.absolutePath, ) + argumentProviders.add { + listOf("-c", configFilePath.get()) + } dependsOn(writeConfigProps) } + val dumpShards = project.providers.gradleProperty("dumpShards") + val execFlank = register("execFlank$name", FlankExecutionTask::class.java, config) execFlank.configure { if (useDefaultDir) setUpWorkingDir(configName) description = "Runs instrumentation tests using flank on firebase test lab." - classpath = project.fladleConfig - args = - if (project.hasProperty("dumpShards")) { - listOf( - "firebase", - "test", - "android", - "run", - "-c", - writeConfigProps - .get() - .fladleConfigFile - .get() - .asFile.absolutePath, - "--dump-shards", - ) - } else { - listOf( - "firebase", - "test", - "android", - "run", - "-c", - writeConfigProps - .get() - .fladleConfigFile - .get() - .asFile.absolutePath, - ) + classpath = fladleConfig + val configFilePath = writeConfigProps.flatMap { it.fladleConfigFile }.map { it.asFile.absolutePath } + argumentProviders.add { + buildList { + add("firebase") + add("test") + add("android") + add("run") + add("-c") + add(configFilePath.get()) + if (dumpShards.isPresent) { + add("--dump-shards") + } } + } if (config.serviceAccountCredentials.isPresent) { environment(mapOf("GOOGLE_APPLICATION_CREDENTIALS" to config.serviceAccountCredentials.get())) } dependsOn(writeConfigProps) if (config.dependOnAssemble.isPresent && config.dependOnAssemble.get()) { - // Find assemble tasks by convention name pattern val variantName = config.variant.orNull if (variantName != null) { val capitalizedVariant = variantName.capitalize() @@ -186,7 +167,6 @@ class FladlePluginDelegate { } if (config.localResultsDir.hasValue) { this.outputs.dir("${workingDir.path}/${config.localResultsDir.get()}") - // This task is never upToDate since it relies on network connections and firebase test lab. this.outputs.upToDateWhen { false } } } @@ -209,15 +189,13 @@ class FladlePluginDelegate { config: FladleConfig, androidExtension: ApplicationExtension, ) { - project.afterEvaluate { - val execution = androidExtension.testOptions.execution.uppercase() - val useOrchestrator = - execution == "ANDROIDX_TEST_ORCHESTRATOR" || - execution == "ANDROID_TEST_ORCHESTRATOR" - if (useOrchestrator) { - log("Automatically detected the use of Android Test Orchestrator") - config.useOrchestrator.set(true) - } + val execution = androidExtension.testOptions.execution.uppercase() + val useOrchestrator = + execution == "ANDROIDX_TEST_ORCHESTRATOR" || + execution == "ANDROID_TEST_ORCHESTRATOR" + if (useOrchestrator) { + project.log("Automatically detected the use of Android Test Orchestrator") + config.useOrchestrator.set(true) } } @@ -229,7 +207,6 @@ class FladlePluginDelegate { requireNotNull( project.extensions.findByType(ApplicationExtension::class.java), ) { "Could not find ApplicationExtension in ${project.name}" } - automaticallyConfigureTestOrchestrator(project, config, androidExtension) val androidComponents = requireNotNull(project.extensions.findByType(ApplicationAndroidComponentsExtension::class.java)) { @@ -240,6 +217,8 @@ class FladlePluginDelegate { if (!variant.isExpectedVariant(config)) return@onVariants val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + automaticallyConfigureTestOrchestrator(project, config, androidExtension) + val buildType = variant.buildType ?: return@onVariants val flavorName = variant.productFlavors.joinToString("") { it.second } val flavorPath = variant.productFlavors.joinToString("/") { it.second } @@ -250,7 +229,6 @@ class FladlePluginDelegate { .get() val buildDir = project.layout.buildDirectory - // Test APK path val testApkDirPath = if (flavorPath.isNotEmpty()) "androidTest/$flavorPath/$buildType" else "androidTest/$buildType" val testApkFileName = if (flavorName.isNotEmpty()) { @@ -285,12 +263,10 @@ class FladlePluginDelegate { .asFile.absolutePath if (!config.debugApk.isPresent) { - // Don't set debug apk if not already set. #172 project.log("Configuring fladle.debugApk from variant ${variant.name}") config.debugApk.set(appApkPath) } if (!config.roboScript.isPresent && !config.instrumentationApk.isPresent && !config.sanityRobo.get()) { - // Don't set instrumentation apk if not already set. #172 project.log("Configuring fladle.instrumentationApk from variant ${variant.name}") config.instrumentationApk.set(testApkPath) } @@ -298,9 +274,6 @@ class FladlePluginDelegate { } } - private val Project.fladleConfig: Configuration - get() = configurations.getByName(FLADLE_CONFIG) - companion object { val GRADLE_MIN_VERSION: GradleVersion = GradleVersion.version("9.1") const val TASK_GROUP = "fladle" diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt index 21bb73aa..c9b2a1bf 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FlankJavaExec.kt @@ -13,11 +13,15 @@ abstract class FlankJavaExec constructor( projectLayout: ProjectLayout, ) : JavaExec() { + // Store only the build directory Provider, not the whole ProjectLayout + // (ProjectLayout may hold a Project reference which is not CC-serializable) + private val buildDir = projectLayout.buildDirectory + init { group = FladlePluginDelegate.TASK_GROUP mainClass.set("ftl.Main") workingDir(projectLayout.fladleDir) } - fun setUpWorkingDir(configName: String) = workingDir(project.layout.buildDirectory.dir("fladle/$configName")) + fun setUpWorkingDir(configName: String) = workingDir(buildDir.dir("fladle/$configName")) } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleMetadataTask.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleMetadataTask.kt new file mode 100644 index 00000000..bbf597c1 --- /dev/null +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModuleMetadataTask.kt @@ -0,0 +1,138 @@ +package com.osacky.flank.gradle + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +@DisableCachingByDefault(because = "This task writes a small metadata file and does not benefit from caching.") +abstract class FulladleModuleMetadataTask : DefaultTask() { + @get:Input + abstract val modulePath: Property + + @get:Input + abstract val moduleType: Property + + @get:Input + abstract val moduleEnabled: Property + + @get:Input + abstract val hasAndroidTestDir: Property + + @get:Input + @get:Optional + abstract val maxTestShards: Property + + @get:Input + @get:Optional + abstract val moduleVariant: Property + + @get:Input + @get:Optional + abstract val debugApk: Property + + @get:Input + abstract val clientDetails: MapProperty + + @get:Input + abstract val environmentVariables: MapProperty + + @get:Input + abstract val variants: ListProperty + + @get:OutputFile + abstract val outputFile: RegularFileProperty + + fun addVariant( + variantName: String, + appApkPath: String?, + testApkPath: String, + abiName: String?, + ) { + val json = + buildString { + append("{") + append("\"variantName\":\"${escapeJson(variantName)}\"") + append(",\"testApkPath\":\"${escapeJson(testApkPath)}\"") + if (appApkPath != null) { + append(",\"appApkPath\":\"${escapeJson(appApkPath)}\"") + } + if (abiName != null) { + append(",\"abiName\":\"${escapeJson(abiName)}\"") + } + append("}") + } + variants.add(json) + } + + @TaskAction + fun writeMetadata() { + val sb = StringBuilder() + sb.append("{\n") + sb.append(" \"modulePath\": \"${escapeJson(modulePath.get())}\",\n") + sb.append(" \"moduleType\": \"${escapeJson(moduleType.get())}\",\n") + sb.append(" \"enabled\": ${moduleEnabled.get()},\n") + sb.append(" \"hasAndroidTestDir\": ${hasAndroidTestDir.get()},\n") + + if (maxTestShards.isPresent) { + sb.append(" \"maxTestShards\": ${maxTestShards.get()},\n") + } + if (moduleVariant.isPresent) { + sb.append(" \"variant\": \"${escapeJson(moduleVariant.get())}\",\n") + } + if (debugApk.isPresent) { + sb.append(" \"debugApk\": \"${escapeJson(debugApk.get())}\",\n") + } + + val cd = clientDetails.getOrElse(emptyMap()) + if (cd.isNotEmpty()) { + sb.append(" \"clientDetails\": {\n") + cd.entries.forEachIndexed { index, entry -> + sb.append(" \"${escapeJson(entry.key)}\": \"${escapeJson(entry.value)}\"") + if (index < cd.size - 1) sb.append(",") + sb.append("\n") + } + sb.append(" },\n") + } + + val env = environmentVariables.getOrElse(emptyMap()) + if (env.isNotEmpty()) { + sb.append(" \"environmentVariables\": {\n") + env.entries.forEachIndexed { index, entry -> + sb.append(" \"${escapeJson(entry.key)}\": \"${escapeJson(entry.value)}\"") + if (index < env.size - 1) sb.append(",") + sb.append("\n") + } + sb.append(" },\n") + } + + val variantList = variants.getOrElse(emptyList()) + sb.append(" \"variants\": [\n") + variantList.forEachIndexed { index, v -> + sb.append(" $v") + if (index < variantList.size - 1) sb.append(",") + sb.append("\n") + } + sb.append(" ]\n") + sb.append("}\n") + + outputFile.get().asFile.apply { + parentFile.mkdirs() + writeText(sb.toString()) + } + } + + private fun escapeJson(value: String): String = + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") +} diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModulePlugin.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModulePlugin.kt new file mode 100644 index 00000000..cb6317b6 --- /dev/null +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleModulePlugin.kt @@ -0,0 +1,154 @@ +package com.osacky.flank.gradle + +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.variant.FilterConfiguration +import com.android.build.api.variant.HasAndroidTest +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.attributes.Attribute +import org.gradle.api.plugins.BasePluginExtension + +class FulladleModulePlugin : Plugin { + override fun apply(project: Project) { + // Create the per-module extension if not already present + if (project.extensions.findByType(FulladleModuleExtension::class.java) == null) { + project.extensions.create("fulladleModuleConfig", FulladleModuleExtension::class.java) + } + + val metadataTask = + project.tasks.register("writeFulladleModuleMetadata", FulladleModuleMetadataTask::class.java) { + modulePath.set(project.path) + moduleType.convention("none") + moduleEnabled.convention(true) + hasAndroidTestDir.convention(false) + clientDetails.convention(emptyMap()) + environmentVariables.convention(emptyMap()) + variants.convention(emptyList()) + outputFile.set(project.layout.buildDirectory.file("fulladle/module-metadata.json")) + } + + // Wire module extension properties into the metadata task + project.afterEvaluate { + val ext = project.extensions.findByType(FulladleModuleExtension::class.java) ?: return@afterEvaluate + metadataTask.configure { + moduleEnabled.set(ext.enabled) + hasAndroidTestDir.set(project.file("src/androidTest").exists()) + if (ext.maxTestShards.isPresent) { + maxTestShards.set(ext.maxTestShards) + } + if (ext.variant.isPresent) { + moduleVariant.set(ext.variant) + } + if (ext.debugApk.isPresent) { + debugApk.set(ext.debugApk) + } + clientDetails.set(ext.clientDetails) + environmentVariables.set(ext.environmentVariables) + } + } + + // Create outgoing configuration for metadata + val outgoing = + project.configurations.create("fulladleModuleMetadataElements") { + isCanBeConsumed = true + isCanBeResolved = false + attributes { + attribute(FULLADLE_USAGE_ATTRIBUTE, "fulladle-module-metadata") + } + } + outgoing.outgoing.artifact(metadataTask.flatMap { it.outputFile }) + + // Wire up variant APK info from Android plugins + project.pluginManager.withPlugin("com.android.application") { + metadataTask.configure { moduleType.set("application") } + val androidComponents = project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java) + androidComponents.onVariants { variant -> + val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + val buildType = variant.buildType ?: return@onVariants + val flavorName = variant.productFlavors.joinToString("") { it.second } + val flavorPath = variant.productFlavors.joinToString("/") { it.second } + val archivesName = + project.extensions + .getByType(BasePluginExtension::class.java) + .archivesName + .get() + + variant.outputs.forEach { output -> + val abiFilter = output.filters.firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI } + val abiName = abiFilter?.identifier + + val appApkDirPath = if (flavorPath.isNotEmpty()) "$flavorPath/$buildType" else buildType + val appApkFileName = + buildString { + append(archivesName) + if (flavorName.isNotEmpty()) append("-$flavorName") + if (abiName != null) append("-$abiName") + append("-$buildType.apk") + } + val appApkPath = + project.layout.buildDirectory + .file("outputs/apk/$appApkDirPath/$appApkFileName") + .get() + .asFile.absolutePath + + val testApkDirPath = + if (flavorPath.isNotEmpty()) "androidTest/$flavorPath/$buildType" else "androidTest/$buildType" + val testApkFileName = + if (flavorName.isNotEmpty()) { + "$archivesName-$flavorName-$buildType-androidTest.apk" + } else { + "$archivesName-$buildType-androidTest.apk" + } + val testApkPath = + project.layout.buildDirectory + .file("outputs/apk/$testApkDirPath/$testApkFileName") + .get() + .asFile.absolutePath + + metadataTask.configure { + addVariant(variant.name, appApkPath, testApkPath, abiName) + } + } + } + } + + project.pluginManager.withPlugin("com.android.library") { + metadataTask.configure { moduleType.set("library") } + val androidComponents = project.extensions.getByType(LibraryAndroidComponentsExtension::class.java) + androidComponents.onVariants { variant -> + val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + val buildType = variant.buildType ?: return@onVariants + val flavorName = variant.productFlavors.joinToString("") { it.second } + val flavorPath = variant.productFlavors.joinToString("/") { it.second } + val archivesName = + project.extensions + .getByType(BasePluginExtension::class.java) + .archivesName + .get() + + val testApkDirPath = + if (flavorPath.isNotEmpty()) "androidTest/$flavorPath/$buildType" else "androidTest/$buildType" + val testApkFileName = + if (flavorName.isNotEmpty()) { + "$archivesName-$flavorName-$buildType-androidTest.apk" + } else { + "$archivesName-$buildType-androidTest.apk" + } + val testApkPath = + project.layout.buildDirectory + .file("outputs/apk/$testApkDirPath/$testApkFileName") + .get() + .asFile.absolutePath + + metadataTask.configure { + addVariant(variant.name, null, testApkPath, null) + } + } + } + } + + companion object { + val FULLADLE_USAGE_ATTRIBUTE: Attribute = Attribute.of("com.osacky.fulladle.usage", String::class.java) + } +} diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt index 59be14be..f662a85f 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt @@ -1,41 +1,99 @@ package com.osacky.flank.gradle -import com.android.build.api.variant.ApplicationAndroidComponentsExtension -import com.android.build.api.variant.FilterConfiguration -import com.android.build.api.variant.HasAndroidTest -import com.android.build.api.variant.LibraryAndroidComponentsExtension import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.plugins.BasePluginExtension -import org.gradle.kotlin.dsl.getByType /** * Like the Fladle plugin, but it configures additionalTestApks for the _full_ project. Hence fulladle. + * + * When the settings plugin (com.osacky.fulladle.settings) is applied, module metadata flows + * through Gradle's configuration system via JSON files. When it is not applied, the legacy + * subprojects {} approach is used for backwards compatibility. */ class FulladlePlugin : Plugin { override fun apply(root: Project) { check(root.parent == null) { "Fulladle must be applied in the root project in order to configure subprojects." } FladlePluginDelegate().apply(root) - val flankGradleExtension = root.extensions.getByType(FlankGradleExtension::class) + val flankGradleExtension = root.extensions.getByType(FlankGradleExtension::class.java) + // Detect whether the settings plugin is in use by checking if any subproject + // has the module plugin applied (which the settings plugin does automatically). + // We defer this check to afterEvaluate so that all plugins have been applied. + root.afterEvaluate { + val settingsPluginActive = + root.subprojects.any { sub -> + sub.plugins.hasPlugin(FulladleModulePlugin::class.java) + } + + if (settingsPluginActive) { + configureWithMetadata(root, flankGradleExtension) + } else { + configureLegacy(root, flankGradleExtension) + } + } + } + + private fun configureWithMetadata( + root: Project, + flankGradleExtension: FlankGradleExtension, + ) { + // Create incoming configuration to collect metadata from subprojects + val incoming = + root.configurations.create("fulladleModuleMetadata") { + isCanBeConsumed = false + isCanBeResolved = true + attributes { + attribute(FulladleModulePlugin.FULLADLE_USAGE_ATTRIBUTE, "fulladle-module-metadata") + } + } + + // Add project dependencies for all subprojects + root.subprojects.forEach { sub -> + incoming.dependencies.add(root.dependencies.project(mapOf("path" to sub.path))) + } + + // Wire metadata files into YamlConfigWriterTask + root.tasks.withType(YamlConfigWriterTask::class.java).configureEach { + moduleMetadataFiles.from(incoming) + if (flankGradleExtension.abi.isPresent) { + abiFilter.set(flankGradleExtension.abi) + } + } + } + + private fun configureLegacy( + root: Project, + flankGradleExtension: FlankGradleExtension, + ) { + // Legacy mode: apply module extensions and variant callbacks directly root.subprojects { - // Yuck, cross project configuration - extensions.create("fulladleModuleConfig", FulladleModuleExtension::class.java) + if (extensions.findByType(FulladleModuleExtension::class.java) == null) { + extensions.create("fulladleModuleConfig", FulladleModuleExtension::class.java) + } - // Register onVariants callbacks to capture APK info during configuration pluginManager.withPlugin("com.android.application") { - val androidComponents = extensions.getByType(ApplicationAndroidComponentsExtension::class.java) + val androidComponents = + extensions.getByType( + com.android.build.api.variant.ApplicationAndroidComponentsExtension::class.java, + ) val ext = extensions.findByType(FulladleModuleExtension::class.java) ?: return@withPlugin androidComponents.onVariants { variant -> - val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + val androidTest = (variant as? com.android.build.api.variant.HasAndroidTest)?.androidTest ?: return@onVariants val buildType = variant.buildType ?: return@onVariants val flavorName = variant.productFlavors.joinToString("") { it.second } val flavorPath = variant.productFlavors.joinToString("/") { it.second } - val archivesName = extensions.getByType(BasePluginExtension::class.java).archivesName.get() + val archivesName = + extensions + .getByType(org.gradle.api.plugins.BasePluginExtension::class.java) + .archivesName + .get() variant.outputs.forEach { output -> - val abiFilter = output.filters.firstOrNull { it.filterType == FilterConfiguration.FilterType.ABI } + val abiFilter = + output.filters.firstOrNull { + it.filterType == com.android.build.api.variant.FilterConfiguration.FilterType.ABI + } val abiName = abiFilter?.identifier val appApkDirPath = if (flavorPath.isNotEmpty()) "$flavorPath/$buildType" else buildType @@ -79,14 +137,21 @@ class FulladlePlugin : Plugin { } pluginManager.withPlugin("com.android.library") { - val androidComponents = extensions.getByType(LibraryAndroidComponentsExtension::class.java) + val androidComponents = + extensions.getByType( + com.android.build.api.variant.LibraryAndroidComponentsExtension::class.java, + ) val ext = extensions.findByType(FulladleModuleExtension::class.java) ?: return@withPlugin androidComponents.onVariants { variant -> - val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants + val androidTest = (variant as? com.android.build.api.variant.HasAndroidTest)?.androidTest ?: return@onVariants val buildType = variant.buildType ?: return@onVariants val flavorName = variant.productFlavors.joinToString("") { it.second } val flavorPath = variant.productFlavors.joinToString("/") { it.second } - val archivesName = extensions.getByType(BasePluginExtension::class.java).archivesName.get() + val archivesName = + extensions + .getByType(org.gradle.api.plugins.BasePluginExtension::class.java) + .archivesName + .get() val testApkDirPath = if (flavorPath.isNotEmpty()) "androidTest/$flavorPath/$buildType" else "androidTest/$buildType" @@ -117,12 +182,6 @@ class FulladlePlugin : Plugin { val fulladleConfigureTask = root.tasks.register("configureFulladle") { var modulesEnabled = false - // We first configure all app modules, then configure all library modules. - // We force this order because app modules are better candidates to become - // root level test/app APKs, since they produce app APKs. - // If no app module had tests or was enabled, we will choose a library module - // to become a root level module, in which case we will have to check if it - // has its debugApk set. doLast { // first configure all app modules root.subprojects { @@ -157,7 +216,6 @@ class FulladlePlugin : Plugin { } root.afterEvaluate { - // TODO add other printYml tasks from other configs root.tasks.named("printYml").configure { dependsOn(fulladleConfigureTask) } @@ -174,8 +232,6 @@ fun configureModule( return } - // Only configure the first test variant per module. - // Does anyone test more than one variant per module? var addedTestsForModule = false for (variantInfo in fulladleModuleExtension.variantApks) { @@ -183,21 +239,16 @@ fun configureModule( if (!variantInfo.isExpectedVariantInModule(fulladleModuleExtension)) continue - // Check ABI filter against the extension if (flankGradleExtension.abi.isPresent && variantInfo.abiName != null && variantInfo.abiName != flankGradleExtension.abi.get()) continue if (flankGradleExtension.abi.isPresent && variantInfo.abiName == null) { // No ABI filter on this output - it's a match (universal) } val yml = StringBuilder() - // If the debugApk isn't yet set, let's use this one. if (!flankGradleExtension.debugApk.isPresent) { if (project.isAndroidAppModule && variantInfo.appApkPath != null) { - // app modules produce app apks that we can consume flankGradleExtension.debugApk.set(rootProject.provider { variantInfo.appApkPath }) } else if (project.isAndroidLibraryModule) { - // library modules do not produce an app apk and we'll use the one specified in fulladleModuleConfig block - // we need library modules to specify the app apk to test against, even if it's a dummy one check(fulladleModuleExtension.debugApk.isPresent && fulladleModuleExtension.debugApk.orNull != null) { "Library module ${project.path} did not specify a debug apk. Library modules do not " + "generate a debug apk and one needs to be specified in the fulladleModuleConfig block\n" + @@ -207,24 +258,19 @@ fun configureModule( flankGradleExtension.debugApk.set(rootProject.provider { fulladleModuleExtension.debugApk.get() }) } } else { - // Otherwise, let's just add it to the list. if (project.isAndroidAppModule && variantInfo.appApkPath != null) { yml.appendLine("- app: ${variantInfo.appApkPath}") } else if (project.isAndroidLibraryModule) { - // app apk is not required for library modules so only use if it's explicitly specified if (fulladleModuleExtension.debugApk.orNull != null) { yml.appendLine("- app: ${fulladleModuleExtension.debugApk.get()}") } } } - // If the instrumentation apk isn't yet set, let's use this one. if (!flankGradleExtension.instrumentationApk.isPresent) { flankGradleExtension.instrumentationApk.set(rootProject.provider { variantInfo.testApkPath }) } else { - // Otherwise, let's just add it to the list. if (yml.isBlank()) { - // The first item in the list needs to start with a ` - `. yml.appendLine("- test: ${variantInfo.testApkPath}") } else { yml.appendLine(" test: ${variantInfo.testApkPath}") @@ -232,8 +278,6 @@ fun configureModule( } if (yml.isEmpty()) { - // this is the root module - // should not be added as additional test apk overrideRootLevelConfigs(flankGradleExtension, fulladleModuleExtension) } else { yml.appendProperty(fulladleModuleExtension.maxTestShards, " max-test-shards") @@ -256,7 +300,6 @@ val Project.isAndroidAppModule val Project.isAndroidLibraryModule get() = plugins.hasPlugin("com.android.library") -// returns false if the module explicitly disabled testing or if it simply had no tests val Project.hasAndroidTest: Boolean get() { if (!(isAndroidLibraryModule || isAndroidAppModule)) { @@ -277,8 +320,6 @@ fun overrideRootLevelConfigs( flankGradleExtension: FlankGradleExtension, fulladleModuleExtension: FulladleModuleExtension, ) { - // if the root module overrode any value in its fulladleModuleConfig block - // then use those values instead val debugApk = fulladleModuleExtension.debugApk.orNull if (debugApk != null && debugApk.isNotEmpty()) { flankGradleExtension.debugApk.set(fulladleModuleExtension.debugApk.get()) diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleSettingsPlugin.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleSettingsPlugin.kt new file mode 100644 index 00000000..e8549a7a --- /dev/null +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladleSettingsPlugin.kt @@ -0,0 +1,14 @@ +package com.osacky.flank.gradle + +import org.gradle.api.Plugin +import org.gradle.api.initialization.Settings + +class FulladleSettingsPlugin : Plugin { + override fun apply(settings: Settings) { + settings.gradle.lifecycle.beforeProject { + if (this != rootProject) { + pluginManager.apply(FulladleModulePlugin::class.java) + } + } + } +} diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/ModuleMetadataParser.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/ModuleMetadataParser.kt new file mode 100644 index 00000000..33d02a6c --- /dev/null +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/ModuleMetadataParser.kt @@ -0,0 +1,255 @@ +package com.osacky.flank.gradle + +import java.io.File + +data class ModuleMetadata( + val modulePath: String, + val moduleType: String, + val enabled: Boolean, + val hasAndroidTestDir: Boolean, + val maxTestShards: Int?, + val variant: String?, + val debugApk: String?, + val clientDetails: Map, + val environmentVariables: Map, + val variants: List, +) + +data class VariantMetadata( + val variantName: String, + val appApkPath: String?, + val testApkPath: String, + val abiName: String?, +) + +data class FulladleAssemblyResult( + val debugApk: String?, + val instrumentationApk: String?, + val additionalTestApks: List, +) + +object ModuleMetadataParser { + fun parseModuleMetadata(files: Set): List = + files + .filter { it.exists() } + .map { parseFile(it) } + + fun assembleFulladleConfig( + modules: List, + abiFilter: String?, + ): FulladleAssemblyResult { + val enabledModules = + modules.filter { it.enabled && it.hasAndroidTestDir } + + check(enabledModules.isNotEmpty()) { + "All modules were disabled for testing in fulladleModuleConfig or the enabled modules had no tests.\n" + + "Either re-enable modules for testing or add modules with tests." + } + + // Sort: app modules first, then library modules (alphabetical within each group) + val sorted = + enabledModules.sortedWith( + compareBy { if (it.moduleType == "application") 0 else 1 } + .thenBy { it.modulePath }, + ) + + var rootDebugApk: String? = null + var rootInstrumentationApk: String? = null + val additionalTestApks = mutableListOf() + + for (module in sorted) { + // Only configure the first matching variant per module + val matchingVariant = findMatchingVariant(module, abiFilter) ?: continue + + val yml = StringBuilder() + + if (rootDebugApk == null) { + // First module becomes root + if (module.moduleType == "application" && matchingVariant.appApkPath != null) { + rootDebugApk = matchingVariant.appApkPath + } else if (module.moduleType == "library") { + check(module.debugApk != null && module.debugApk.isNotEmpty()) { + "Library module ${module.modulePath} did not specify a debug apk. Library modules do not " + + "generate a debug apk and one needs to be specified in the fulladleModuleConfig block\n" + + "This is a required parameter in FTL which remains unused for library modules under test, " + + "and you can use a dummy apk here" + } + rootDebugApk = module.debugApk + } + } else { + // Additional module + if (module.moduleType == "application" && matchingVariant.appApkPath != null) { + yml.appendLine("- app: ${matchingVariant.appApkPath}") + } else if (module.moduleType == "library") { + if (module.debugApk != null && module.debugApk.isNotEmpty()) { + yml.appendLine("- app: ${module.debugApk}") + } + } + } + + if (rootInstrumentationApk == null) { + rootInstrumentationApk = matchingVariant.testApkPath + } else { + if (yml.isBlank()) { + yml.appendLine("- test: ${matchingVariant.testApkPath}") + } else { + yml.appendLine(" test: ${matchingVariant.testApkPath}") + } + } + + if (yml.isEmpty()) { + // This is the root module - apply overrides + // (overrides are returned via the rootDebugApk/rootInstrumentationApk above) + } else { + // Append per-module overrides + val maxTestShards = module.maxTestShards + if (maxTestShards != null && maxTestShards > 0) { + yml.appendLine(" max-test-shards: $maxTestShards") + } + if (module.clientDetails.isNotEmpty()) { + yml.appendLine(" client-details:") + module.clientDetails.forEach { (key, value) -> + yml.appendLine(" $key: $value") + } + } + if (module.environmentVariables.isNotEmpty()) { + yml.appendLine(" environment-variables:") + module.environmentVariables.forEach { (key, value) -> + yml.appendLine(" $key: $value") + } + } + additionalTestApks.add(yml.toString()) + } + } + + return FulladleAssemblyResult( + debugApk = rootDebugApk, + instrumentationApk = rootInstrumentationApk, + additionalTestApks = additionalTestApks, + ) + } + + private fun findMatchingVariant( + module: ModuleMetadata, + abiFilter: String?, + ): VariantMetadata? { + for (variant in module.variants) { + // Check variant filter + if (module.variant != null && !variant.variantName.contains(module.variant)) continue + + // Check ABI filter + if (abiFilter != null && variant.abiName != null && variant.abiName != abiFilter) continue + + return variant + } + return null + } + + // Simple JSON parser - no external dependency needed + internal fun parseFile(file: File): ModuleMetadata { + val content = file.readText() + return parseJson(content) + } + + internal fun parseJson(json: String): ModuleMetadata { + val modulePath = extractString(json, "modulePath") ?: "" + val moduleType = extractString(json, "moduleType") ?: "" + val enabled = extractBoolean(json, "enabled") ?: true + val hasAndroidTestDir = extractBoolean(json, "hasAndroidTestDir") ?: false + val maxTestShards = extractInt(json, "maxTestShards") + val variant = extractString(json, "variant") + val debugApk = extractString(json, "debugApk") + val clientDetails = extractMap(json, "clientDetails") + val environmentVariables = extractMap(json, "environmentVariables") + val variants = extractVariants(json) + + return ModuleMetadata( + modulePath = modulePath, + moduleType = moduleType, + enabled = enabled, + hasAndroidTestDir = hasAndroidTestDir, + maxTestShards = maxTestShards, + variant = variant, + debugApk = debugApk, + clientDetails = clientDetails, + environmentVariables = environmentVariables, + variants = variants, + ) + } + + private fun extractString( + json: String, + key: String, + ): String? { + val pattern = "\"$key\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"".toRegex() + val match = pattern.find(json) ?: return null + return unescapeJson(match.groupValues[1]) + } + + private fun extractBoolean( + json: String, + key: String, + ): Boolean? { + val pattern = "\"$key\"\\s*:\\s*(true|false)".toRegex() + val match = pattern.find(json) ?: return null + return match.groupValues[1].toBoolean() + } + + private fun extractInt( + json: String, + key: String, + ): Int? { + val pattern = "\"$key\"\\s*:\\s*(-?\\d+)".toRegex() + val match = pattern.find(json) ?: return null + return match.groupValues[1].toInt() + } + + private fun extractMap( + json: String, + key: String, + ): Map { + val result = mutableMapOf() + val mapPattern = "\"$key\"\\s*:\\s*\\{([^}]*)}".toRegex() + val mapMatch = mapPattern.find(json) ?: return result + val mapContent = mapMatch.groupValues[1] + val entryPattern = "\"((?:[^\"\\\\]|\\\\.)*)\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"".toRegex() + entryPattern.findAll(mapContent).forEach { entry -> + result[unescapeJson(entry.groupValues[1])] = unescapeJson(entry.groupValues[2]) + } + return result + } + + private fun extractVariants(json: String): List { + val variants = mutableListOf() + val arrayPattern = "\"variants\"\\s*:\\s*\\[([^\\]]*)]".toRegex(RegexOption.DOT_MATCHES_ALL) + val arrayMatch = arrayPattern.find(json) ?: return variants + val arrayContent = arrayMatch.groupValues[1] + + // Match each object in the array + val objectPattern = "\\{([^}]*)}".toRegex() + objectPattern.findAll(arrayContent).forEach { objMatch -> + val obj = objMatch.value + val variantName = extractString(obj, "variantName") ?: return@forEach + val testApkPath = extractString(obj, "testApkPath") ?: return@forEach + val appApkPath = extractString(obj, "appApkPath") + val abiName = extractString(obj, "abiName") + variants.add( + VariantMetadata( + variantName = variantName, + appApkPath = appApkPath, + testApkPath = testApkPath, + abiName = abiName, + ), + ) + } + return variants + } + + private fun unescapeJson(value: String): String = + value + .replace("\\\\", "\\") + .replace("\\\"", "\"") + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") +} diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt index 1d3b09e6..a2335348 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlConfigWriterTask.kt @@ -1,15 +1,21 @@ package com.osacky.flank.gradle import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.ProjectLayout import org.gradle.api.file.RegularFile import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property import org.gradle.api.provider.Provider import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Nested +import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.work.DisableCachingByDefault import java.util.Locale @@ -23,7 +29,7 @@ open class YamlConfigWriterTask @get:Nested val config: FladleConfig, @get:Input val configName: String, projectLayout: ProjectLayout, - objects: ObjectFactory, + private val objects: ObjectFactory, ) : DefaultTask() { private val yamlWriter = YamlWriter() @@ -42,6 +48,15 @@ open class YamlConfigWriterTask .listProperty(String::class.java) .convention(config.additionalTestApks) + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + @get:Optional + val moduleMetadataFiles: ConfigurableFileCollection = objects.fileCollection() + + @get:Input + @get:Optional + val abiFilter: Property = objects.property(String::class.java) + @OutputFile val fladleConfigFile: Provider = fladleDir.map { it.file("flank.yml") } @@ -54,11 +69,65 @@ open class YamlConfigWriterTask @TaskAction fun writeFile() { fladleDir.get().asFile.mkdirs() + + var metadataDebugApk: String? = null + var metadataInstrumentationApk: String? = null + val metadataAdditionalApks = mutableListOf() + + // If module metadata files are present (settings plugin path), + // parse them and assemble the fulladle config + val metadataFiles = moduleMetadataFiles.files + if (metadataFiles.isNotEmpty()) { + val modules = ModuleMetadataParser.parseModuleMetadata(metadataFiles) + val assemblyResult = ModuleMetadataParser.assembleFulladleConfig(modules, abiFilter.orNull) + + if (assemblyResult.debugApk != null && !config.debugApk.isPresent) { + metadataDebugApk = assemblyResult.debugApk + } + if (assemblyResult.instrumentationApk != null && !config.instrumentationApk.isPresent) { + metadataInstrumentationApk = assemblyResult.instrumentationApk + } + metadataAdditionalApks.addAll(assemblyResult.additionalTestApks) + } + + // Create override properties using ObjectFactory (proper Gradle properties) + val debugApkOverride: Property = + objects.property(String::class.java).apply { + if (metadataDebugApk != null) { + set(metadataDebugApk) + } else if (config.debugApk.isPresent) { + set(config.debugApk.get()) + } + } + val instrumentationApkOverride: Property = + objects.property(String::class.java).apply { + if (metadataInstrumentationApk != null) { + set(metadataInstrumentationApk) + } else if (config.instrumentationApk.isPresent) { + set(config.instrumentationApk.get()) + } + } + + // Merge configured additional test apks with metadata-based ones + val mergedAdditionalTestApks: ListProperty = + objects.listProperty(String::class.java).apply { + addAll(additionalTestApks.get()) + addAll(metadataAdditionalApks) + } + + // Create a merged config that overlays metadata values without mutating the extension val mergedConfig = object : FladleConfig by config { override val additionalTestApks: ListProperty - get() = this@YamlConfigWriterTask.additionalTestApks + get() = mergedAdditionalTestApks + + override val debugApk: Property + get() = debugApkOverride + + override val instrumentationApk: Property + get() = instrumentationApkOverride } - fladleConfigFile.get().asFile.writeText(yamlWriter.createConfigProps(mergedConfig, base)) + + fladleConfigFile.get().asFile.writeText(yamlWriter.createConfigProps(mergedConfig, mergedConfig)) } } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt index d2083f64..a56e7b3c 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/YamlWriter.kt @@ -1,11 +1,9 @@ package com.osacky.flank.gradle -import org.gradle.internal.impldep.com.google.common.annotations.VisibleForTesting - internal class YamlWriter { internal fun createConfigProps( config: FladleConfig, - base: FlankGradleExtension, + base: FladleConfig, ): String { fun Boolean.toInt() = if (this) 1 else 0 @@ -145,7 +143,6 @@ internal class YamlWriter { appendAdditionalProperty(config.additionalGcloudOptions) } - @VisibleForTesting internal fun createDeviceString(devices: List>): String = buildString { appendLine(" device:") diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/ModuleMetadataParserTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/ModuleMetadataParserTest.kt new file mode 100644 index 00000000..a712a5bc --- /dev/null +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/ModuleMetadataParserTest.kt @@ -0,0 +1,358 @@ +package com.osacky.flank.gradle + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.io.File + +class ModuleMetadataParserTest { + @Test + fun `parse valid metadata JSON`() { + val json = + """ + { + "modulePath": ":app", + "moduleType": "application", + "enabled": true, + "hasAndroidTestDir": true, + "maxTestShards": 4, + "variant": "debug", + "debugApk": "/path/to/debug.apk", + "clientDetails": { + "test-type": "PR", + "build-number": "132" + }, + "environmentVariables": { + "clearPackageData": "true" + }, + "variants": [ + {"variantName":"debug","testApkPath":"/path/to/test.apk","appApkPath":"/path/to/app.apk"} + ] + } + """.trimIndent() + + val result = ModuleMetadataParser.parseJson(json) + + assertThat(result.modulePath).isEqualTo(":app") + assertThat(result.moduleType).isEqualTo("application") + assertThat(result.enabled).isTrue() + assertThat(result.hasAndroidTestDir).isTrue() + assertThat(result.maxTestShards).isEqualTo(4) + assertThat(result.variant).isEqualTo("debug") + assertThat(result.debugApk).isEqualTo("/path/to/debug.apk") + assertThat(result.clientDetails).containsExactly("test-type", "PR", "build-number", "132") + assertThat(result.environmentVariables).containsExactly("clearPackageData", "true") + assertThat(result.variants).hasSize(1) + assertThat(result.variants[0].variantName).isEqualTo("debug") + assertThat(result.variants[0].testApkPath).isEqualTo("/path/to/test.apk") + assertThat(result.variants[0].appApkPath).isEqualTo("/path/to/app.apk") + assertThat(result.variants[0].abiName).isNull() + } + + @Test + fun `parse metadata without optional fields`() { + val json = + """ + { + "modulePath": ":lib", + "moduleType": "library", + "enabled": true, + "hasAndroidTestDir": true, + "variants": [ + {"variantName":"debug","testApkPath":"/path/to/test.apk"} + ] + } + """.trimIndent() + + val result = ModuleMetadataParser.parseJson(json) + + assertThat(result.modulePath).isEqualTo(":lib") + assertThat(result.moduleType).isEqualTo("library") + assertThat(result.maxTestShards).isNull() + assertThat(result.variant).isNull() + assertThat(result.debugApk).isNull() + assertThat(result.clientDetails).isEmpty() + assertThat(result.environmentVariables).isEmpty() + assertThat(result.variants).hasSize(1) + assertThat(result.variants[0].appApkPath).isNull() + assertThat(result.variants[0].abiName).isNull() + } + + @Test + fun `parse metadata with ABI split`() { + val json = + """ + { + "modulePath": ":app", + "moduleType": "application", + "enabled": true, + "hasAndroidTestDir": true, + "variants": [ + {"variantName":"debug","testApkPath":"/test.apk","appApkPath":"/app-x86.apk","abiName":"x86"}, + {"variantName":"debug","testApkPath":"/test.apk","appApkPath":"/app-arm.apk","abiName":"armeabi-v7a"} + ] + } + """.trimIndent() + + val result = ModuleMetadataParser.parseJson(json) + + assertThat(result.variants).hasSize(2) + assertThat(result.variants[0].abiName).isEqualTo("x86") + assertThat(result.variants[1].abiName).isEqualTo("armeabi-v7a") + } + + @Test + fun `assemble config with app and library modules`() { + val appModule = + ModuleMetadata( + modulePath = ":app", + moduleType = "application", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", "/app/debug.apk", "/app/test.apk", null), + ), + ) + val libModule = + ModuleMetadata( + modulePath = ":lib", + moduleType = "library", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", null, "/lib/test.apk", null), + ), + ) + + val result = ModuleMetadataParser.assembleFulladleConfig(listOf(appModule, libModule), null) + + assertThat(result.debugApk).isEqualTo("/app/debug.apk") + assertThat(result.instrumentationApk).isEqualTo("/app/test.apk") + assertThat(result.additionalTestApks).hasSize(1) + assertThat(result.additionalTestApks[0]).contains("- test: /lib/test.apk") + } + + @Test + fun `assemble config with disabled module`() { + val enabledModule = + ModuleMetadata( + modulePath = ":app", + moduleType = "application", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", "/app/debug.apk", "/app/test.apk", null), + ), + ) + val disabledModule = + ModuleMetadata( + modulePath = ":lib", + moduleType = "library", + enabled = false, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", null, "/lib/test.apk", null), + ), + ) + + val result = ModuleMetadataParser.assembleFulladleConfig(listOf(enabledModule, disabledModule), null) + + assertThat(result.debugApk).isEqualTo("/app/debug.apk") + assertThat(result.instrumentationApk).isEqualTo("/app/test.apk") + assertThat(result.additionalTestApks).isEmpty() + } + + @Test(expected = IllegalStateException::class) + fun `assemble config throws when all modules disabled`() { + val disabledModule = + ModuleMetadata( + modulePath = ":app", + moduleType = "application", + enabled = false, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", "/app/debug.apk", "/app/test.apk", null), + ), + ) + + ModuleMetadataParser.assembleFulladleConfig(listOf(disabledModule), null) + } + + @Test + fun `assemble config with ABI filter`() { + val module = + ModuleMetadata( + modulePath = ":app", + moduleType = "application", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", "/app-x86.apk", "/test.apk", "x86"), + VariantMetadata("debug", "/app-arm.apk", "/test.apk", "armeabi-v7a"), + ), + ) + + val result = ModuleMetadataParser.assembleFulladleConfig(listOf(module), "armeabi-v7a") + + assertThat(result.debugApk).isEqualTo("/app-arm.apk") + } + + @Test + fun `assemble config with module-level overrides`() { + val appModule = + ModuleMetadata( + modulePath = ":app", + moduleType = "application", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", "/app/debug.apk", "/app/test.apk", null), + ), + ) + val libModule = + ModuleMetadata( + modulePath = ":lib", + moduleType = "library", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = 4, + variant = null, + debugApk = null, + clientDetails = mapOf("test-type" to "PR"), + environmentVariables = mapOf("clearPackageData" to "false"), + variants = + listOf( + VariantMetadata("debug", null, "/lib/test.apk", null), + ), + ) + + val result = ModuleMetadataParser.assembleFulladleConfig(listOf(appModule, libModule), null) + + assertThat(result.additionalTestApks).hasSize(1) + val apkEntry = result.additionalTestApks[0] + assertThat(apkEntry).contains("max-test-shards: 4") + assertThat(apkEntry).contains("client-details:") + assertThat(apkEntry).contains("test-type: PR") + assertThat(apkEntry).contains("environment-variables:") + assertThat(apkEntry).contains("clearPackageData: false") + } + + @Test(expected = IllegalStateException::class) + fun `library module without debugApk as root throws`() { + val libModule = + ModuleMetadata( + modulePath = ":lib", + moduleType = "library", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = null, + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", null, "/lib/test.apk", null), + ), + ) + + ModuleMetadataParser.assembleFulladleConfig(listOf(libModule), null) + } + + @Test + fun `library module with debugApk as root succeeds`() { + val libModule = + ModuleMetadata( + modulePath = ":lib", + moduleType = "library", + enabled = true, + hasAndroidTestDir = true, + maxTestShards = null, + variant = null, + debugApk = "dummy_app.apk", + clientDetails = emptyMap(), + environmentVariables = emptyMap(), + variants = + listOf( + VariantMetadata("debug", null, "/lib/test.apk", null), + ), + ) + + val result = ModuleMetadataParser.assembleFulladleConfig(listOf(libModule), null) + + assertThat(result.debugApk).isEqualTo("dummy_app.apk") + assertThat(result.instrumentationApk).isEqualTo("/lib/test.apk") + assertThat(result.additionalTestApks).isEmpty() + } + + @Test + fun `parse metadata files from disk`() { + val tempDir = File(System.getProperty("java.io.tmpdir"), "fulladle-test-${System.nanoTime()}") + tempDir.mkdirs() + try { + val file1 = + File(tempDir, "module1.json").apply { + writeText( + """ + { + "modulePath": ":app", + "moduleType": "application", + "enabled": true, + "hasAndroidTestDir": true, + "variants": [ + {"variantName":"debug","testApkPath":"/test.apk","appApkPath":"/app.apk"} + ] + } + """.trimIndent(), + ) + } + + val results = ModuleMetadataParser.parseModuleMetadata(setOf(file1)) + assertThat(results).hasSize(1) + assertThat(results[0].modulePath).isEqualTo(":app") + } finally { + tempDir.deleteRecursively() + } + } +} diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladleSettingsPluginTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladleSettingsPluginTest.kt new file mode 100644 index 00000000..033121cc --- /dev/null +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladleSettingsPluginTest.kt @@ -0,0 +1,323 @@ +package com.osacky.flank.gradle.integration + +import com.google.common.truth.Truth.assertThat +import org.gradle.testkit.runner.GradleRunner +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class FulladleSettingsPluginTest { + @get:Rule + var testProjectRoot = TemporaryFolder() + + val agpDependency: String = "com.android.tools.build:gradle:9.0.1" + + fun writeBuildGradle(build: String) { + val file = testProjectRoot.newFile("build.gradle") + file.writeText(build) + } + + fun writeSettingsGradle(vararg includes: String) { + testProjectRoot.newFile("settings.gradle").writeText( + """ + plugins { + id "com.osacky.fulladle.settings" + } + ${includes.joinToString("\n") { "include '$it'" }} + + dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } + } + """.trimIndent(), + ) + } + + @Test + fun `settings plugin with submodules`() { + val appFixture = "android-project" + val libraryFixture = "android-library-project" + writeSettingsGradle(appFixture, libraryFixture) + testProjectRoot.setupFixture(appFixture) + testProjectRoot.setupFixture(libraryFixture) + + writeBuildGradle( + """ + buildscript { + repositories { + google() + } + dependencies { + classpath '$agpDependency' + } + } + plugins { + id "com.osacky.fulladle" + } + fladle { + serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") + } + """.trimIndent(), + ) + + val result = + testProjectRoot + .gradleRunner() + .withArguments(":printYml") + .build() + + assertThat(result.output).contains("SUCCESS") + assertThat(result.output).containsMatch( + """ + > Task :printYml + gcloud: + app: [0-9a-zA-Z\/_]*/android-project/build/outputs/apk/debug/android-project-debug.apk + test: [0-9a-zA-Z\/_]*/android-project/build/outputs/apk/androidTest/debug/android-project-debug-androidTest.apk + """.trimIndent(), + ) + assertThat(result.output).contains("additional-app-test-apks:") + } + + @Test + fun `settings plugin with disabled module`() { + val appFixture = "android-project" + val libraryFixture = "android-library-project" + val ignoredLibraryProject = "android-lib-ignored" + writeSettingsGradle(appFixture, libraryFixture, ignoredLibraryProject) + testProjectRoot.setupFixture(appFixture) + testProjectRoot.setupFixture(libraryFixture) + File(testProjectRoot.root, libraryFixture).copyRecursively(testProjectRoot.newFile(ignoredLibraryProject), overwrite = true) + + writeBuildGradle( + """ + buildscript { + repositories { + google() + } + dependencies { + classpath '$agpDependency' + } + } + plugins { + id "com.osacky.fulladle" + } + fladle { + serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") + } + """.trimIndent(), + ) + + // Disable the ignored library project + File(testProjectRoot.root, "$ignoredLibraryProject/build.gradle").appendText( + """ + fulladleModuleConfig { + enabled = false + } + """.trimIndent(), + ) + + val result = + testProjectRoot + .gradleRunner() + .withArguments(":printYml") + .build() + + assertThat(result.output).contains("SUCCESS") + } + + @Test + fun `settings plugin with submodule overrides`() { + val appFixture = "android-project" + val appFixture2 = "android-project2" + val libraryFixture = "android-library-project" + val libraryFixture2 = "android-lib2" + writeSettingsGradle(appFixture, appFixture2, libraryFixture, libraryFixture2) + testProjectRoot.setupFixture(appFixture) + testProjectRoot.setupFixture(appFixture2) + testProjectRoot.setupFixture(libraryFixture) + File(testProjectRoot.root, libraryFixture).copyRecursively(testProjectRoot.newFile(libraryFixture2), overwrite = true) + + writeBuildGradle( + """ + buildscript { + repositories { + google() + } + dependencies { + classpath '$agpDependency' + } + } + plugins { + id "com.osacky.fulladle" + } + fladle { + serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") + environmentVariables = [ + "clearPackageData": "true", + "listener": "com.osacky.flank.sample.Listener" + ] + } + """.trimIndent(), + ) + + File(testProjectRoot.root, "$libraryFixture2/build.gradle").appendText( + """ + fulladleModuleConfig { + maxTestShards = 4 + clientDetails = ["test-type": "PR","build-number": "132"] + } + """.trimIndent(), + ) + + File(testProjectRoot.root, "$libraryFixture/build.gradle").appendText( + """ + fulladleModuleConfig { + maxTestShards = 7 + environmentVariables = [ + "clearPackageData": "false", + "listener": "com.osacky.flank.sample.Listener.Different" + ] + debugApk = "dummy_app.apk" + } + """.trimIndent(), + ) + + val result = + testProjectRoot + .gradleRunner() + .withArguments(":printYml") + .build() + + assertThat(result.output).contains("SUCCESS") + assertThat(result.output).contains("additional-app-test-apks:") + } + + @Test + fun `settings plugin with flavors`() { + val appFixture = "android-project" + val libraryFixture = "android-library-project" + val flavourProject = "android-project-flavors" + val flavourLibrary = "android-library-project-flavors" + writeSettingsGradle(appFixture, libraryFixture, flavourProject, flavourLibrary) + testProjectRoot.setupFixture(appFixture) + testProjectRoot.setupFixture(libraryFixture) + testProjectRoot.setupFixture(flavourProject) + testProjectRoot.setupFixture(flavourLibrary) + + writeBuildGradle( + """ + buildscript { + repositories { + google() + } + dependencies { + classpath '$agpDependency' + } + } + plugins { + id "com.osacky.fulladle" + } + fladle { + serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") + } + """.trimIndent(), + ) + + File(testProjectRoot.root, "$flavourProject/build.gradle").appendText( + """ + fulladleModuleConfig { + variant = "vanillaDebug" + } + """.trimIndent(), + ) + + File(testProjectRoot.root, "$flavourLibrary/build.gradle").appendText( + """ + fulladleModuleConfig { + variant = "strawberryDebug" + } + """.trimIndent(), + ) + + val result = + testProjectRoot + .gradleRunner() + .withArguments(":printYml") + .build() + + assertThat(result.output).contains("SUCCESS") + assertThat(result.output).contains("additional-app-test-apks:") + } + + @Test + fun `settings plugin smoke test`() { + testProjectRoot.newFile("settings.gradle").writeText( + """ + plugins { + id "com.osacky.fulladle.settings" + } + """.trimIndent(), + ) + writeBuildGradle( + """ + plugins { + id "com.osacky.fulladle" + } + """.trimIndent(), + ) + val result = + testProjectRoot + .gradleRunner() + .withArguments("help") + .build() + assertThat(result.output).contains("SUCCESS") + } + + @Test + fun `settings plugin with non-Android module`() { + val appFixture = "android-project" + val libraryFixture = "android-library-project" + val nonAndroidFixture = "lib1" + writeSettingsGradle(appFixture, libraryFixture, nonAndroidFixture) + testProjectRoot.setupFixture(appFixture) + testProjectRoot.setupFixture(libraryFixture) + File(testProjectRoot.root, libraryFixture).copyRecursively(testProjectRoot.newFile(nonAndroidFixture), overwrite = true) + + writeBuildGradle( + """ + buildscript { + repositories { + google() + } + dependencies { + classpath '$agpDependency' + } + } + plugins { + id "com.osacky.fulladle" + } + fladle { + serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") + } + """.trimIndent(), + ) + + // Replace the non-Android module's build.gradle to be a pure Java library + File(testProjectRoot.root, "$nonAndroidFixture/build.gradle").writeText( + """ + apply plugin: 'java-library' + """.trimIndent(), + ) + + val result = + testProjectRoot + .gradleRunner() + .withArguments(":printYml") + .build() + + assertThat(result.output).contains("SUCCESS") + } +} From 515ba1fcb4985e79206e7eedf8bfe4096ea43226 Mon Sep 17 00:00:00 2001 From: Matthew Runo <74583+inktomi@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:20:09 -0800 Subject: [PATCH 5/6] Improve configuration cache support and add settings plugin docs - Use gradle.projectsEvaluated instead of afterEvaluate in FulladlePlugin so legacy path sees fully evaluated subprojects without needing evaluationDependsOnChildren (which breaks other plugins) - Move configuration creation and extension setup to apply() phase - Move automaticallyConfigureTestOrchestrator before onVariants loop - Add CC multi-module integration tests for both legacy and settings paths - Document settings plugin in multi-module-testing.md and changelog --- docs/changelog.md | 1 + docs/multi-module-testing.md | 29 +++- .../flank/gradle/FladlePluginDelegate.kt | 4 +- .../com/osacky/flank/gradle/FulladlePlugin.kt | 153 ++++++++++-------- .../integration/ConfigurationCacheTest.kt | 105 ++++++++++++ .../FulladlePluginIntegrationTest.kt | 1 - 6 files changed, 220 insertions(+), 73 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 08f2d165..a9b4d79b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,7 @@ ## Unreleased * Minimum required Gradle version is now 9.1 * Fixed support for Android Gradle Plugin version 9.0.1 +* Added `com.osacky.fulladle.settings` settings plugin for Gradle configuration cache compatible multi-module testing ## 0.19.0 * Minimum required JVM version is now 17. diff --git a/docs/multi-module-testing.md b/docs/multi-module-testing.md index 5489dd64..ef5545d5 100644 --- a/docs/multi-module-testing.md +++ b/docs/multi-module-testing.md @@ -4,7 +4,30 @@ Multi module testing can be done by manually specifying [additionalTestApks](/fl ## Fulladle Plugin -1. Apply the Fulladle plugin at the root of the project. +There are two ways to set up Fulladle: with or without the settings plugin. + +**The settings plugin (`com.osacky.fulladle.settings`) is the recommended approach** for projects using Gradle's [configuration cache](https://docs.gradle.org/current/userguide/configuration_cache.html). It avoids cross-project configuration by passing module metadata through Gradle's dependency management system. Without the settings plugin, Fulladle falls back to a legacy approach that uses `subprojects {}` — this still works but is not compatible with future Gradle best practices. + +### Setup + +1. *(Recommended)* Apply the settings plugin in `settings.gradle`: + + === "Groovy" + ``` groovy + plugins { + id 'com.osacky.fulladle.settings' version '{{ fladle.current_release }}' + } + ``` + === "Kotlin" + ``` kotlin + plugins { + id("com.osacky.fulladle.settings") version "{{ fladle.current_release }}" + } + ``` + + The settings plugin automatically applies the module plugin to every subproject, so no per-module setup is needed. + +2. Apply the Fulladle plugin at the root of the project. === "Groovy" ``` groovy @@ -19,7 +42,7 @@ Multi module testing can be done by manually specifying [additionalTestApks](/fl } ``` -2. Configure the Fladle extension. +3. Configure the Fladle extension. ===! "Groovy" ``` groovy @@ -37,7 +60,7 @@ Multi module testing can be done by manually specifying [additionalTestApks](/fl !!! Warning If using buildFlavors or testing against a non default variant, you will need to specify the variant you want to test in the fulladleModuleConfig block. -3. Run the tests. +4. Run the tests. First assemble all your debug apks and test apks. ``` bash ./gradlew assembleDebug assembleDebugAndroidTest diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt index 526cac03..59393e05 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FladlePluginDelegate.kt @@ -212,12 +212,12 @@ class FladlePluginDelegate { "Could not find ApplicationAndroidComponentsExtension in ${project.name}" } + automaticallyConfigureTestOrchestrator(project, config, androidExtension) + androidComponents.onVariants { variant -> if (!variant.isExpectedVariant(config)) return@onVariants val androidTest = (variant as? HasAndroidTest)?.androidTest ?: return@onVariants - automaticallyConfigureTestOrchestrator(project, config, androidExtension) - val buildType = variant.buildType ?: return@onVariants val flavorName = variant.productFlavors.joinToString("") { it.second } val flavorPath = variant.productFlavors.joinToString("/") { it.second } diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt index f662a85f..cea6f0a6 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt @@ -17,28 +17,8 @@ class FulladlePlugin : Plugin { val flankGradleExtension = root.extensions.getByType(FlankGradleExtension::class.java) - // Detect whether the settings plugin is in use by checking if any subproject - // has the module plugin applied (which the settings plugin does automatically). - // We defer this check to afterEvaluate so that all plugins have been applied. - root.afterEvaluate { - val settingsPluginActive = - root.subprojects.any { sub -> - sub.plugins.hasPlugin(FulladleModulePlugin::class.java) - } - - if (settingsPluginActive) { - configureWithMetadata(root, flankGradleExtension) - } else { - configureLegacy(root, flankGradleExtension) - } - } - } - - private fun configureWithMetadata( - root: Project, - flankGradleExtension: FlankGradleExtension, - ) { - // Create incoming configuration to collect metadata from subprojects + // Create incoming configuration eagerly — creating configurations in afterEvaluate + // is discouraged and may be disallowed in future Gradle versions. val incoming = root.configurations.create("fulladleModuleMetadata") { isCanBeConsumed = false @@ -48,25 +28,35 @@ class FulladlePlugin : Plugin { } } - // Add project dependencies for all subprojects - root.subprojects.forEach { sub -> - incoming.dependencies.add(root.dependencies.project(mapOf("path" to sub.path))) - } + // Eagerly create module extensions and register variant callbacks on all subprojects. + // This must happen during apply() — before subprojects evaluate — so that + // fulladleModuleConfig is available in subproject build scripts. + setupSubprojectExtensions(root) - // Wire metadata files into YamlConfigWriterTask - root.tasks.withType(YamlConfigWriterTask::class.java).configureEach { - moduleMetadataFiles.from(incoming) - if (flankGradleExtension.abi.isPresent) { - abiFilter.set(flankGradleExtension.abi) + // Use gradle.projectsEvaluated so that ALL projects (root + subprojects) have been + // fully evaluated before we inspect their plugins and extensions. This avoids needing + // evaluationDependsOnChildren() which breaks plugins applied after fulladle that + // use afterEvaluate internally. + root.gradle.projectsEvaluated { + val settingsPluginActive = + root.subprojects.any { sub -> + sub.plugins.hasPlugin(FulladleModulePlugin::class.java) + } + + if (settingsPluginActive) { + configureWithMetadata(root, flankGradleExtension, incoming) + } else { + configureLegacy(root, flankGradleExtension) } } } - private fun configureLegacy( - root: Project, - flankGradleExtension: FlankGradleExtension, - ) { - // Legacy mode: apply module extensions and variant callbacks directly + /** + * Creates the fulladleModuleConfig extension and registers onVariants callbacks + * on each subproject. These run eagerly during apply() so the extension is + * available when subproject build scripts are evaluated. + */ + private fun setupSubprojectExtensions(root: Project) { root.subprojects { if (extensions.findByType(FulladleModuleExtension::class.java) == null) { extensions.create("fulladleModuleConfig", FulladleModuleExtension::class.java) @@ -178,48 +168,77 @@ class FulladlePlugin : Plugin { } } } + } + + private fun configureWithMetadata( + root: Project, + flankGradleExtension: FlankGradleExtension, + incoming: org.gradle.api.artifacts.Configuration, + ) { + // Add project dependencies for all subprojects + root.subprojects.forEach { sub -> + incoming.dependencies.add(root.dependencies.project(mapOf("path" to sub.path))) + } + + // Wire metadata files into YamlConfigWriterTask + root.tasks.withType(YamlConfigWriterTask::class.java).configureEach { + moduleMetadataFiles.from(incoming) + if (flankGradleExtension.abi.isPresent) { + abiFilter.set(flankGradleExtension.abi) + } + } + } + private fun configureLegacy( + root: Project, + flankGradleExtension: FlankGradleExtension, + ) { val fulladleConfigureTask = root.tasks.register("configureFulladle") { - var modulesEnabled = false - doLast { - // first configure all app modules - root.subprojects { - if (!hasAndroidTest) { - return@subprojects - } - modulesEnabled = true - if (isAndroidAppModule) { - configureModule(this, flankGradleExtension) - } - } - // then configure all library modules - root.subprojects { - if (!hasAndroidTest) { - return@subprojects - } - modulesEnabled = true - if (isAndroidLibraryModule) { - configureModule(this, flankGradleExtension) - } - } - - check(modulesEnabled) { - "All modules were disabled for testing in fulladleModuleConfig or the enabled modules had no tests.\n" + - "Either re-enable modules for testing or add modules with tests." - } - } + // Validation check is configured below. + // Module configuration happens at configuration time (in the projectsEvaluated block) + // to avoid capturing Project references in the task action (configuration cache). } root.tasks.withType(YamlConfigWriterTask::class.java).configureEach { dependsOn(fulladleConfigureTask) } - root.afterEvaluate { - root.tasks.named("printYml").configure { - dependsOn(fulladleConfigureTask) + var modulesEnabled = false + // first configure all app modules + root.subprojects { + if (!hasAndroidTest) { + return@subprojects + } + modulesEnabled = true + if (isAndroidAppModule) { + configureModule(this, flankGradleExtension) + } + } + // then configure all library modules + root.subprojects { + if (!hasAndroidTest) { + return@subprojects + } + modulesEnabled = true + if (isAndroidLibraryModule) { + configureModule(this, flankGradleExtension) + } + } + + val allModulesEnabled = modulesEnabled + fulladleConfigureTask.configure { + doLast { + check(allModulesEnabled) { + "All modules were disabled for testing in fulladleModuleConfig or the enabled modules had no tests.\n" + + "Either re-enable modules for testing or add modules with tests." + } } } + + root.tasks.named("printYml").configure { + dependsOn(fulladleConfigureTask) + } } } diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt index 83cbf547..aae6aff1 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/ConfigurationCacheTest.kt @@ -10,6 +10,8 @@ class ConfigurationCacheTest { @get:Rule var testProjectRoot = TemporaryFolder() + val agpDependency: String = "com.android.tools.build:gradle:9.0.1" + fun writeBuildGradle(build: String) { val file = testProjectRoot.newFile("build.gradle") file.writeText(build) @@ -131,6 +133,109 @@ class ConfigurationCacheTest { assertThat(secondResult.output).contains("Reusing configuration cache.") } + @Test + fun fulladleMultiModuleWithConfigurationCache() { + val appFixture = "android-project" + val libraryFixture = "android-library-project" + testProjectRoot.newFile("settings.gradle").writeText( + """ + include '$appFixture' + include '$libraryFixture' + + dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } + } + """.trimIndent(), + ) + testProjectRoot.setupFixture(appFixture) + testProjectRoot.setupFixture(libraryFixture) + + writeBuildGradle( + """ + buildscript { + repositories { + google() + } + dependencies { + classpath '$agpDependency' + } + } + plugins { + id "com.osacky.fulladle" + } + fladle { + serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") + } + """.trimIndent(), + ) + + val result = configCachingRunner("printYml").build() + + assertThat(result.output).contains("SUCCESS") + assertThat(result.output).contains("additional-app-test-apks:") + + val secondResult = configCachingRunner("printYml").build() + + assertThat(secondResult.output).contains("Reusing configuration cache.") + assertThat(secondResult.output).contains("SUCCESS") + } + + @Test + fun fulladleSettingsPluginMultiModuleWithConfigurationCache() { + val appFixture = "android-project" + val libraryFixture = "android-library-project" + testProjectRoot.newFile("settings.gradle").writeText( + """ + plugins { + id "com.osacky.fulladle.settings" + } + include '$appFixture' + include '$libraryFixture' + + dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } + } + """.trimIndent(), + ) + testProjectRoot.setupFixture(appFixture) + testProjectRoot.setupFixture(libraryFixture) + + writeBuildGradle( + """ + buildscript { + repositories { + google() + } + dependencies { + classpath '$agpDependency' + } + } + plugins { + id "com.osacky.fulladle" + } + fladle { + serviceAccountCredentials = project.layout.projectDirectory.file("android-project/flank-gradle-5cf02dc90531.json") + } + """.trimIndent(), + ) + + val result = configCachingRunner("printYml").build() + + assertThat(result.output).contains("SUCCESS") + assertThat(result.output).contains("additional-app-test-apks:") + + val secondResult = configCachingRunner("printYml").build() + + assertThat(secondResult.output).contains("Reusing configuration cache.") + assertThat(secondResult.output).contains("SUCCESS") + } + private fun configCachingRunner(arg: String): GradleRunner = GradleRunner .create() diff --git a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt index 7d2a3ecf..3dce0811 100644 --- a/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt +++ b/fladle-plugin/src/test/java/com/osacky/flank/gradle/integration/FulladlePluginIntegrationTest.kt @@ -720,7 +720,6 @@ class FulladlePluginIntegrationTest { .withArguments(":printYml") .buildAndFail() - assertThat(result.output).contains("Task :configureFulladle FAILED") assertThat(result.output).contains( "Library module :android-library-project did not specify a debug apk. Library modules do not " + "generate a debug apk and one needs to be specified in the fulladleModuleConfig block", From c2f756ca5c28c39327910da4bc0fc2272b430f5e Mon Sep 17 00:00:00 2001 From: Matthew Runo <74583+inktomi@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:27:27 -0800 Subject: [PATCH 6/6] Restore explanatory comments in FulladlePlugin configureModule logic --- .../com/osacky/flank/gradle/FulladlePlugin.kt | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt index cea6f0a6..2425afa9 100644 --- a/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt +++ b/fladle-plugin/src/main/java/com/osacky/flank/gradle/FulladlePlugin.kt @@ -205,7 +205,12 @@ class FulladlePlugin : Plugin { } var modulesEnabled = false - // first configure all app modules + // We first configure all app modules, then configure all library modules. + // We force this order because app modules are better candidates to become + // root level test/app APKs, since they produce app APKs. + // If no app module had tests or was enabled, we will choose a library module + // to become a root level module, in which case we will have to check if it + // has its debugApk set. root.subprojects { if (!hasAndroidTest) { return@subprojects @@ -251,6 +256,7 @@ fun configureModule( return } + // Only configure the first test variant per module. var addedTestsForModule = false for (variantInfo in fulladleModuleExtension.variantApks) { @@ -264,6 +270,9 @@ fun configureModule( } val yml = StringBuilder() + // If the debugApk isn't yet set, use this module's APK as the root. + // App modules produce app APKs directly; library modules must specify one + // in their fulladleModuleConfig block (even a dummy APK, since FTL requires it). if (!flankGradleExtension.debugApk.isPresent) { if (project.isAndroidAppModule && variantInfo.appApkPath != null) { flankGradleExtension.debugApk.set(rootProject.provider { variantInfo.appApkPath }) @@ -277,15 +286,18 @@ fun configureModule( flankGradleExtension.debugApk.set(rootProject.provider { fulladleModuleExtension.debugApk.get() }) } } else { + // debugApk already set — add this module as an additional test APK. if (project.isAndroidAppModule && variantInfo.appApkPath != null) { yml.appendLine("- app: ${variantInfo.appApkPath}") } else if (project.isAndroidLibraryModule) { + // app apk is not required for library modules so only use if explicitly specified if (fulladleModuleExtension.debugApk.orNull != null) { yml.appendLine("- app: ${fulladleModuleExtension.debugApk.get()}") } } } + // Same pattern for instrumentation APK: first module becomes root, rest are additional. if (!flankGradleExtension.instrumentationApk.isPresent) { flankGradleExtension.instrumentationApk.set(rootProject.provider { variantInfo.testApkPath }) } else { @@ -297,6 +309,8 @@ fun configureModule( } if (yml.isEmpty()) { + // This is the root module — not added as additional test APK. + // Apply any per-module overrides to the root-level config. overrideRootLevelConfigs(flankGradleExtension, fulladleModuleExtension) } else { yml.appendProperty(fulladleModuleExtension.maxTestShards, " max-test-shards") @@ -319,6 +333,7 @@ val Project.isAndroidAppModule val Project.isAndroidLibraryModule get() = plugins.hasPlugin("com.android.library") +/** Returns false if the module explicitly disabled testing or if it simply had no tests. */ val Project.hasAndroidTest: Boolean get() { if (!(isAndroidLibraryModule || isAndroidAppModule)) { @@ -335,6 +350,7 @@ val Project.hasAndroidTest: Boolean return true } +/** If the root module overrode any value in its fulladleModuleConfig block, use those values instead. */ fun overrideRootLevelConfigs( flankGradleExtension: FlankGradleExtension, fulladleModuleExtension: FulladleModuleExtension,