Skip to content

Commit 627a6db

Browse files
committed
Add coverage configuration to plugins
1 parent d5c3a65 commit 627a6db

File tree

6 files changed

+266
-3
lines changed

6 files changed

+266
-3
lines changed

gradle/libs.versions.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,20 @@ kotlin = "2.0.21"
44
detekt = "1.23.8"
55
spotless = "8.0.0"
66
kotlinDokka = "2.0.0"
7-
gradlePluginPublish = "2.0.0"
87
mavenPublish = "0.34.0"
8+
sonarqube = "6.0.1.5171"
9+
kover = "0.9.3"
910

1011
[libraries]
1112
android-gradle-plugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" }
1213
kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
1314
spotless-gradle-plugin = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" }
15+
sonarqube-gradle-plugin = { group = "org.sonarsource.scanner.gradle", name = "sonarqube-gradle-plugin", version.ref = "sonarqube" }
16+
kover-gradle-plugin = { group = "org.jetbrains.kotlinx", name = "kover-gradle-plugin", version.ref = "kover" }
1417

1518
[plugins]
1619
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
1720
dokka = { id = "org.jetbrains.dokka-javadoc", version.ref = "kotlinDokka" }
18-
gradle-plugin-publish = { id = "com.gradle.plugin-publish", version.ref = "gradlePluginPublish" }
1921
maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
2022
detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }
2123
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }

plugin/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ dependencies {
2424
compileOnly(libs.android.gradle.plugin)
2525
compileOnly(libs.kotlin.gradle.plugin)
2626
implementation(libs.spotless.gradle.plugin)
27+
implementation(libs.sonarqube.gradle.plugin)
28+
implementation(libs.kover.gradle.plugin)
2729
}
2830

2931
val repoId = "GetStream/stream-build-conventions-android"

plugin/src/main/kotlin/io/getstream/android/StreamConventionExtensions.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.getstream.android
1717

18+
import io.getstream.android.coverage.CoverageOptions
1819
import io.getstream.android.spotless.SpotlessOptions
1920
import javax.inject.Inject
2021
import org.gradle.api.Action
@@ -23,6 +24,7 @@ import org.gradle.api.model.ObjectFactory
2324
import org.gradle.api.provider.Property
2425
import org.gradle.kotlin.dsl.create
2526
import org.gradle.kotlin.dsl.findByType
27+
import org.gradle.kotlin.dsl.newInstance
2628
import org.gradle.kotlin.dsl.property
2729

2830
/**
@@ -38,10 +40,16 @@ constructor(project: Project, objects: ObjectFactory) {
3840
objects.property<String>().convention(project.provider { project.rootProject.name })
3941

4042
/** Spotless formatting configuration */
41-
val spotless: SpotlessOptions = objects.newInstance(SpotlessOptions::class.java)
43+
val spotless: SpotlessOptions = objects.newInstance<SpotlessOptions>()
4244

4345
/** Configure Spotless formatting */
4446
fun spotless(action: Action<SpotlessOptions>) = action.execute(spotless)
47+
48+
/** Code coverage configuration */
49+
val coverage: CoverageOptions = objects.newInstance<CoverageOptions>()
50+
51+
/** Configure code coverage */
52+
fun coverage(action: Action<CoverageOptions>) = action.execute(coverage)
4553
}
4654

4755
internal fun Project.createProjectExtension(): StreamProjectExtension =

plugin/src/main/kotlin/io/getstream/android/StreamConventionPlugins.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ package io.getstream.android
1818
import com.android.build.api.dsl.ApplicationExtension
1919
import com.android.build.api.dsl.LibraryExtension
2020
import com.android.build.api.dsl.TestExtension
21+
import io.getstream.android.coverage.configureCoverageModule
22+
import io.getstream.android.coverage.configureCoverageRoot
2123
import io.getstream.android.spotless.configureSpotless
2224
import org.gradle.api.Plugin
2325
import org.gradle.api.Project
@@ -34,6 +36,7 @@ class RootConventionPlugin : Plugin<Project> {
3436
}
3537

3638
createProjectExtension()
39+
configureCoverageRoot()
3740
}
3841
}
3942
}
@@ -46,6 +49,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
4649
configureAndroid<ApplicationExtension>()
4750
configureKotlin()
4851
configureSpotless()
52+
configureCoverageModule()
4953
}
5054
}
5155
}
@@ -58,6 +62,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
5862
configureAndroid<LibraryExtension>()
5963
configureKotlin()
6064
configureSpotless()
65+
configureCoverageModule()
6166
}
6267
}
6368
}
@@ -82,6 +87,7 @@ class JavaLibraryConventionPlugin : Plugin<Project> {
8287
configureJava()
8388
configureKotlin()
8489
configureSpotless()
90+
configureCoverageModule()
8591
}
8692
}
8793
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.getstream.android.coverage
17+
18+
import com.android.build.api.dsl.ApplicationExtension
19+
import com.android.build.api.dsl.CommonExtension
20+
import com.android.build.api.dsl.LibraryExtension
21+
import io.getstream.android.StreamProjectExtension
22+
import io.getstream.android.requireStreamProjectExtension
23+
import kotlinx.kover.gradle.plugin.dsl.KoverProjectExtension
24+
import org.gradle.api.Project
25+
import org.gradle.kotlin.dsl.configure
26+
import org.sonarqube.gradle.SonarExtension
27+
28+
private object SonarDefaults {
29+
const val HOST_URL = "https://sonarcloud.io"
30+
31+
const val ORGANIZATION = "getstream"
32+
33+
val EXCLUSIONS =
34+
listOf(
35+
"**/test/**",
36+
"**/androidTest/**",
37+
"**/R.class",
38+
"**/R2.class",
39+
"**/R$*.class",
40+
"**/BuildConfig.*",
41+
"**/Manifest*.*",
42+
"**/*Test*.*",
43+
)
44+
}
45+
46+
object KoverDefaults {
47+
const val VARIANT_NAME = "coverage"
48+
const val VARIANT_SUFFIX = "Coverage"
49+
const val TEST_TASK = "test$VARIANT_SUFFIX"
50+
51+
val CLASS_EXCLUSIONS = listOf("*R", "*R$*", "*BuildConfig", "*Manifest*", "*Composable*")
52+
53+
val ANNOTATION_EXCLUSIONS = arrayOf("androidx.compose.ui.tooling.preview.Preview")
54+
}
55+
56+
internal fun Project.configureCoverageRoot() {
57+
pluginManager.apply("org.sonarqube")
58+
59+
afterEvaluate {
60+
val projectExtension = requireStreamProjectExtension()
61+
val includedModules = projectExtension.coverage.includedModules.get()
62+
63+
configureKover(projectExtension.coverage, isRoot = true)
64+
setupKoverDependencyOnModules(includedModules)
65+
configureSonar(projectExtension)
66+
registerAggregatedCoverageTask(includedModules)
67+
}
68+
}
69+
70+
private fun Project.configureSonar(extension: StreamProjectExtension) {
71+
val repositoryName = extension.repositoryName.get()
72+
val exclusions = buildList {
73+
addAll(SonarDefaults.EXCLUSIONS)
74+
addAll(extension.coverage.sonarCoverageExclusions.get())
75+
}
76+
77+
extensions.configure<SonarExtension> {
78+
properties {
79+
property("sonar.host.url", SonarDefaults.HOST_URL)
80+
property("sonar.token", System.getenv("SONAR_TOKEN"))
81+
property("sonar.organization", SonarDefaults.ORGANIZATION)
82+
property("sonar.projectKey", "GetStream_$repositoryName")
83+
property("sonar.projectName", repositoryName)
84+
property("sonar.java.coveragePlugin", "jacoco")
85+
property("sonar.sourceEncoding", "UTF-8")
86+
property("sonar.java.binaries", "$rootDir/**/build/tmp/kotlin-classes/debug")
87+
property("sonar.coverage.exclusions", exclusions)
88+
property(
89+
"sonar.coverage.jacoco.xmlReportPaths",
90+
layout.buildDirectory
91+
.file("/reports/kover/report${KoverDefaults.VARIANT_SUFFIX}.xml")
92+
.get(),
93+
)
94+
}
95+
}
96+
}
97+
98+
private fun Project.setupKoverDependencyOnModules(includedModules: Set<String>) {
99+
subprojects.forEach { subproject ->
100+
if (subproject.name in includedModules) {
101+
dependencies.add("kover", subproject)
102+
}
103+
}
104+
}
105+
106+
internal fun Project.configureCoverageModule() {
107+
val coverageOptions = requireStreamProjectExtension().coverage
108+
109+
// Do not configure coverage for ignored modules
110+
if (name !in coverageOptions.includedModules.get()) {
111+
return
112+
}
113+
114+
pluginManager.apply("org.sonarqube")
115+
116+
// Configure Android test coverage if this is an Android module
117+
pluginManager.withPlugin("com.android.library") { configureAndroid<LibraryExtension>() }
118+
pluginManager.withPlugin("com.android.application") { configureAndroid<ApplicationExtension>() }
119+
120+
configureKover(coverageOptions, isRoot = false)
121+
registerModuleCoverageTask()
122+
}
123+
124+
private fun Project.registerAggregatedCoverageTask(includedModules: Set<String>) {
125+
tasks.register(KoverDefaults.TEST_TASK) {
126+
group = "verification"
127+
description = "Run all tests in all modules and generate merged coverage report"
128+
129+
// Depend on all module-specific testCoverage tasks
130+
val coverageModuleTasks =
131+
subprojects
132+
.filter { it.name in includedModules }
133+
.map { ":${it.name}:${KoverDefaults.TEST_TASK}" }
134+
dependsOn(coverageModuleTasks)
135+
136+
finalizedBy(
137+
"koverXmlReport${KoverDefaults.VARIANT_SUFFIX}",
138+
"koverHtmlReport${KoverDefaults.VARIANT_SUFFIX}",
139+
)
140+
}
141+
}
142+
143+
private fun Project.registerModuleCoverageTask() {
144+
// Determine the appropriate test task based on module type and plugins
145+
val hasPaparazziPlugin = pluginManager.hasPlugin("app.cash.paparazzi")
146+
val hasAndroidPlugin =
147+
pluginManager.hasPlugin("com.android.library") ||
148+
pluginManager.hasPlugin("com.android.application")
149+
150+
val testTaskName =
151+
when {
152+
hasPaparazziPlugin -> "verifyPaparazziDebug"
153+
hasAndroidPlugin -> "testDebugUnitTest"
154+
else -> "test"
155+
}
156+
157+
tasks.register(KoverDefaults.TEST_TASK) {
158+
group = "verification"
159+
description = "Run module-specific tests"
160+
dependsOn(testTaskName)
161+
}
162+
}
163+
164+
private inline fun <reified E : CommonExtension<*, *, *, *, *, *>> Project.configureAndroid() {
165+
extensions.configure<E> {
166+
buildTypes {
167+
getByName("debug") {
168+
enableUnitTestCoverage = true
169+
enableAndroidTestCoverage = true
170+
}
171+
}
172+
}
173+
}
174+
175+
private fun Project.configureKover(options: CoverageOptions, isRoot: Boolean) {
176+
pluginManager.apply("org.jetbrains.kotlinx.kover")
177+
178+
extensions.configure<KoverProjectExtension> {
179+
// Create custom variant in each project (including root) for coverage aggregation
180+
currentProject {
181+
createVariant(KoverDefaults.VARIANT_NAME) {
182+
if (isRoot) {
183+
// Root variant is empty, it just aggregates from dependencies
184+
} else {
185+
add("jvm", "debug", optional = true)
186+
}
187+
}
188+
}
189+
190+
reports {
191+
verify.warningInsteadOfFailure.set(true)
192+
193+
filters.excludes {
194+
classes(KoverDefaults.CLASS_EXCLUSIONS)
195+
classes(options.koverClassExclusions.get())
196+
197+
annotatedBy(*KoverDefaults.ANNOTATION_EXCLUSIONS)
198+
}
199+
}
200+
}
201+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-build-conventions-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.getstream.android.coverage
17+
18+
import javax.inject.Inject
19+
import org.gradle.api.model.ObjectFactory
20+
import org.gradle.api.provider.ListProperty
21+
import org.gradle.api.provider.SetProperty
22+
import org.gradle.kotlin.dsl.listProperty
23+
import org.gradle.kotlin.dsl.setProperty
24+
25+
abstract class CoverageOptions @Inject constructor(objects: ObjectFactory) {
26+
/** Modules to include in coverage analysis. Default: none */
27+
val includedModules: SetProperty<String> = objects.setProperty<String>().convention(emptySet())
28+
29+
/**
30+
* Additional Kover exclusion patterns beyond the defaults. Default exclusions include tests,
31+
* generated code, etc. Expected patterns matching classes/packages, e.g. "*SomeClass",
32+
* "io.getstream.some.package.*"
33+
*/
34+
val koverClassExclusions: ListProperty<String> =
35+
objects.listProperty<String>().convention(emptyList())
36+
37+
/**
38+
* Additional Sonar coverage exclusion patterns beyond the defaults. Default exclusions include
39+
* tests, generated code, etc. Expected patterns matching file paths, e.g.
40+
* "&#42;&#42;/io/getstream/some/package/&#42;&#42;"
41+
*/
42+
val sonarCoverageExclusions: ListProperty<String> =
43+
objects.listProperty<String>().convention(emptyList())
44+
}

0 commit comments

Comments
 (0)