diff --git a/README.md b/README.md index 4bd477c..fed6454 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,17 @@ streamProject { // Exclude file patterns from Spotless formatting (default: empty) excludePatterns = setOf("**/generated/**") } + + coverage { + // Modules to include in coverage analysis (default: empty) + includedModules = setOf("some-module", "some-ui-module") + + // Additional Kover exclusion patterns for classes/packages (default: empty) + koverClassExclusions = listOf("*SomeClass", "io.getstream.some.package.*") + + // Additional Sonar coverage exclusion patterns for file paths (default: empty) + sonarCoverageExclusions = listOf("**/io/getstream/some/package/**") + } } ``` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 559c729..98ea927 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,18 +4,20 @@ kotlin = "2.0.21" detekt = "1.23.8" spotless = "8.0.0" kotlinDokka = "2.0.0" -gradlePluginPublish = "2.0.0" mavenPublish = "0.34.0" +sonarqube = "6.0.1.5171" +kover = "0.9.3" [libraries] android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } spotless-gradle-plugin = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" } +sonarqube-gradle-plugin = { group = "org.sonarsource.scanner.gradle", name = "sonarqube-gradle-plugin", version.ref = "sonarqube" } +kover-gradle-plugin = { group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } dokka = { id = "org.jetbrains.dokka-javadoc", version.ref = "kotlinDokka" } -gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "gradlePluginPublish" } maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/plugin/build.gradle.kts b/plugin/build.gradle.kts index f636d86..40d56c6 100644 --- a/plugin/build.gradle.kts +++ b/plugin/build.gradle.kts @@ -24,6 +24,8 @@ dependencies { compileOnly(libs.android.gradle.plugin) compileOnly(libs.kotlin.gradle.plugin) implementation(libs.spotless.gradle.plugin) + implementation(libs.sonarqube.gradle.plugin) + implementation(libs.kover.gradle.plugin) } val repoId = "GetStream/stream-build-conventions-android" diff --git a/plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt b/plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt index e38f42a..b89676d 100644 --- a/plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt +++ b/plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt @@ -15,6 +15,7 @@ */ package io.getstream.android +import io.getstream.android.coverage.CoverageOptions import io.getstream.android.spotless.SpotlessOptions import javax.inject.Inject import org.gradle.api.Action @@ -23,6 +24,7 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import org.gradle.kotlin.dsl.create import org.gradle.kotlin.dsl.findByType +import org.gradle.kotlin.dsl.newInstance import org.gradle.kotlin.dsl.property /** @@ -38,10 +40,16 @@ constructor(project: Project, objects: ObjectFactory) { objects.property().convention(project.provider { project.rootProject.name }) /** Spotless formatting configuration */ - val spotless: SpotlessOptions = objects.newInstance(SpotlessOptions::class.java) + val spotless: SpotlessOptions = objects.newInstance() /** Configure Spotless formatting */ fun spotless(action: Action) = action.execute(spotless) + + /** Code coverage configuration */ + val coverage: CoverageOptions = objects.newInstance() + + /** Configure code coverage */ + fun coverage(action: Action) = action.execute(coverage) } internal fun Project.createProjectExtension(): StreamProjectExtension = diff --git a/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt b/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt index 298c648..3f3f6e0 100644 --- a/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt +++ b/plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt @@ -18,6 +18,8 @@ package io.getstream.android import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.TestExtension +import io.getstream.android.coverage.configureCoverageModule +import io.getstream.android.coverage.configureCoverageRoot import io.getstream.android.spotless.configureSpotless import org.gradle.api.Plugin import org.gradle.api.Project @@ -34,6 +36,7 @@ class RootConventionPlugin : Plugin { } createProjectExtension() + configureCoverageRoot() } } } @@ -46,6 +49,7 @@ class AndroidApplicationConventionPlugin : Plugin { configureAndroid() configureKotlin() configureSpotless() + configureCoverageModule() } } } @@ -58,6 +62,7 @@ class AndroidLibraryConventionPlugin : Plugin { configureAndroid() configureKotlin() configureSpotless() + configureCoverageModule() } } } @@ -82,6 +87,7 @@ class JavaLibraryConventionPlugin : Plugin { configureJava() configureKotlin() configureSpotless() + configureCoverageModule() } } } diff --git a/plugin/src/main/kotlin/io/getstream/android/coverage/CoverageConfiguration.kt b/plugin/src/main/kotlin/io/getstream/android/coverage/CoverageConfiguration.kt new file mode 100644 index 0000000..8c9e3d8 --- /dev/null +++ b/plugin/src/main/kotlin/io/getstream/android/coverage/CoverageConfiguration.kt @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.coverage + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.LibraryExtension +import io.getstream.android.StreamProjectExtension +import io.getstream.android.requireStreamProjectExtension +import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.sonarqube.gradle.SonarExtension + +private object SonarConstants { + const val HOST_URL = "https://sonarcloud.io" + const val ORGANIZATION = "getstream" + + val EXCLUSIONS = + listOf( + "**/test/**", + "**/androidTest/**", + "**/R.class", + "**/R2.class", + "**/R$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/*Test*.*", + ) +} + +object KoverConstants { + const val VARIANT_NAME = "coverage" + const val VARIANT_SUFFIX = "Coverage" + const val TEST_TASK = "test$VARIANT_SUFFIX" + val CLASS_EXCLUSIONS = listOf("*R", "*R$*", "*BuildConfig", "*Manifest*", "*Composable*") + val ANNOTATION_EXCLUSIONS = arrayOf("androidx.compose.ui.tooling.preview.Preview") +} + +internal fun Project.configureCoverageRoot() { + pluginManager.apply("org.sonarqube") + + afterEvaluate { + val projectExtension = requireStreamProjectExtension() + val includedModules = projectExtension.coverage.includedModules.get() + + configureKover(projectExtension.coverage, isRoot = true) + setupKoverDependencyOnModules(includedModules) + configureSonar(projectExtension) + registerAggregatedCoverageTask(includedModules) + } +} + +private fun Project.configureSonar(extension: StreamProjectExtension) { + val repositoryName = extension.repositoryName.get() + val exclusions = buildList { + addAll(SonarConstants.EXCLUSIONS) + addAll(extension.coverage.sonarCoverageExclusions.get()) + } + + extensions.configure { + properties { + property("sonar.host.url", SonarConstants.HOST_URL) + property("sonar.token", System.getenv("SONAR_TOKEN")) + property("sonar.organization", SonarConstants.ORGANIZATION) + property("sonar.projectKey", "GetStream_$repositoryName") + property("sonar.projectName", repositoryName) + property("sonar.java.coveragePlugin", "jacoco") + property("sonar.sourceEncoding", "UTF-8") + property("sonar.java.binaries", "$rootDir/**/build/tmp/kotlin-classes/debug") + property("sonar.coverage.exclusions", exclusions) + property( + "sonar.coverage.jacoco.xmlReportPaths", + layout.buildDirectory + .file("/reports/kover/report${KoverConstants.VARIANT_SUFFIX}.xml") + .get(), + ) + } + } +} + +private fun Project.setupKoverDependencyOnModules(includedModules: Set) { + subprojects.forEach { subproject -> + if (subproject.name in includedModules) { + dependencies.add("kover", subproject) + } + } +} + +internal fun Project.configureCoverageModule() { + val coverageOptions = requireStreamProjectExtension().coverage + + // Only configure coverage for included modules + if (name !in coverageOptions.includedModules.get()) { + return + } + + pluginManager.apply("org.sonarqube") + + // Configure Android test coverage if this is an Android module + pluginManager.withPlugin("com.android.library") { configureAndroid() } + pluginManager.withPlugin("com.android.application") { configureAndroid() } + + configureKover(coverageOptions, isRoot = false) + registerModuleCoverageTask() +} + +private fun Project.registerAggregatedCoverageTask(includedModules: Set) { + tasks.register(KoverConstants.TEST_TASK) { + group = "verification" + description = "Run all tests in all modules and generate merged coverage report" + + // Depend on all module-specific testCoverage tasks + val coverageModuleTasks = + subprojects + .filter { it.name in includedModules } + .map { ":${it.name}:${KoverConstants.TEST_TASK}" } + dependsOn(coverageModuleTasks) + + finalizedBy( + "koverXmlReport${KoverConstants.VARIANT_SUFFIX}", + "koverHtmlReport${KoverConstants.VARIANT_SUFFIX}", + ) + } +} + +private fun Project.registerModuleCoverageTask() { + // Determine the appropriate test task based on module type and plugins + val hasPaparazziPlugin = pluginManager.hasPlugin("app.cash.paparazzi") + val hasAndroidPlugin = + pluginManager.hasPlugin("com.android.library") || + pluginManager.hasPlugin("com.android.application") + + val testTaskName = + when { + hasPaparazziPlugin -> "verifyPaparazziDebug" + hasAndroidPlugin -> "testDebugUnitTest" + else -> "test" + } + + tasks.register(KoverConstants.TEST_TASK) { + group = "verification" + description = "Run module-specific tests" + dependsOn(testTaskName) + } +} + +private inline fun > Project.configureAndroid() { + extensions.configure { + buildTypes { + getByName("debug") { + enableUnitTestCoverage = true + enableAndroidTestCoverage = true + } + } + } +} + +private fun Project.configureKover(options: CoverageOptions, isRoot: Boolean) { + pluginManager.apply("org.jetbrains.kotlinx.kover") + + extensions.configure { + // Create custom variant in each project (including root) for coverage aggregation + currentProject { + createVariant(KoverConstants.VARIANT_NAME) { + if (isRoot) { + // Root variant is empty, it just aggregates from dependencies + } else { + add("jvm", "debug", optional = true) + } + } + } + + reports { + verify.warningInsteadOfFailure.set(true) + + filters.excludes { + classes(KoverConstants.CLASS_EXCLUSIONS) + classes(options.koverClassExclusions.get()) + + annotatedBy(*KoverConstants.ANNOTATION_EXCLUSIONS) + } + } + } +} diff --git a/plugin/src/main/kotlin/io/getstream/android/coverage/CoverageOptions.kt b/plugin/src/main/kotlin/io/getstream/android/coverage/CoverageOptions.kt new file mode 100644 index 0000000..4257d17 --- /dev/null +++ b/plugin/src/main/kotlin/io/getstream/android/coverage/CoverageOptions.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.getstream.android.coverage + +import javax.inject.Inject +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.SetProperty +import org.gradle.kotlin.dsl.listProperty +import org.gradle.kotlin.dsl.setProperty + +abstract class CoverageOptions @Inject constructor(objects: ObjectFactory) { + /** Modules to include in coverage analysis. Default: none */ + val includedModules: SetProperty = objects.setProperty().convention(emptySet()) + + /** + * Additional Kover exclusion patterns beyond the defaults. Default exclusions include tests, + * generated code, etc. Expected patterns matching classes/packages, e.g. "*SomeClass", + * "io.getstream.some.package.*" + */ + val koverClassExclusions: ListProperty = + objects.listProperty().convention(emptyList()) + + /** + * Additional Sonar coverage exclusion patterns beyond the defaults. Default exclusions include + * tests, generated code, etc. Expected patterns matching file paths, e.g. + * "**/io/getstream/some/package/**" + */ + val sonarCoverageExclusions: ListProperty = + objects.listProperty().convention(emptyList()) +}