Skip to content

Commit c850470

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

File tree

7 files changed

+274
-3
lines changed

7 files changed

+274
-3
lines changed

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ streamProject {
4444
// Exclude file patterns from Spotless formatting (default: empty)
4545
excludePatterns = setOf("**/generated/**")
4646
}
47+
48+
coverage {
49+
// Modules to include in coverage analysis (default: empty)
50+
includedModules = setOf("some-module", "some-ui-module")
51+
52+
// Additional Kover exclusion patterns for classes/packages (default: empty)
53+
koverClassExclusions = listOf("*SomeClass", "io.getstream.some.package.*")
54+
55+
// Additional Sonar coverage exclusion patterns for file paths (default: empty)
56+
sonarCoverageExclusions = listOf("**/io/getstream/some/package/**")
57+
}
4758
}
4859
```
4960

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: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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 SonarConstants {
29+
const val HOST_URL = "https://sonarcloud.io"
30+
const val ORGANIZATION = "getstream"
31+
32+
val EXCLUSIONS =
33+
listOf(
34+
"**/test/**",
35+
"**/androidTest/**",
36+
"**/R.class",
37+
"**/R2.class",
38+
"**/R$*.class",
39+
"**/BuildConfig.*",
40+
"**/Manifest*.*",
41+
"**/*Test*.*",
42+
)
43+
}
44+
45+
object KoverConstants {
46+
const val VARIANT_NAME = "coverage"
47+
const val VARIANT_SUFFIX = "Coverage"
48+
const val TEST_TASK = "test$VARIANT_SUFFIX"
49+
val CLASS_EXCLUSIONS = listOf("*R", "*R$*", "*BuildConfig", "*Manifest*", "*Composable*")
50+
val ANNOTATION_EXCLUSIONS = arrayOf("androidx.compose.ui.tooling.preview.Preview")
51+
}
52+
53+
internal fun Project.configureCoverageRoot() {
54+
pluginManager.apply("org.sonarqube")
55+
56+
afterEvaluate {
57+
val projectExtension = requireStreamProjectExtension()
58+
val includedModules = projectExtension.coverage.includedModules.get()
59+
60+
configureKover(projectExtension.coverage, isRoot = true)
61+
setupKoverDependencyOnModules(includedModules)
62+
configureSonar(projectExtension)
63+
registerAggregatedCoverageTask(includedModules)
64+
}
65+
}
66+
67+
private fun Project.configureSonar(extension: StreamProjectExtension) {
68+
val repositoryName = extension.repositoryName.get()
69+
val exclusions = buildList {
70+
addAll(SonarConstants.EXCLUSIONS)
71+
addAll(extension.coverage.sonarCoverageExclusions.get())
72+
}
73+
74+
extensions.configure<SonarExtension> {
75+
properties {
76+
property("sonar.host.url", SonarConstants.HOST_URL)
77+
property("sonar.token", System.getenv("SONAR_TOKEN"))
78+
property("sonar.organization", SonarConstants.ORGANIZATION)
79+
property("sonar.projectKey", "GetStream_$repositoryName")
80+
property("sonar.projectName", repositoryName)
81+
property("sonar.java.coveragePlugin", "jacoco")
82+
property("sonar.sourceEncoding", "UTF-8")
83+
property("sonar.java.binaries", "$rootDir/**/build/tmp/kotlin-classes/debug")
84+
property("sonar.coverage.exclusions", exclusions)
85+
property(
86+
"sonar.coverage.jacoco.xmlReportPaths",
87+
layout.buildDirectory
88+
.file("/reports/kover/report${KoverConstants.VARIANT_SUFFIX}.xml")
89+
.get(),
90+
)
91+
}
92+
}
93+
}
94+
95+
private fun Project.setupKoverDependencyOnModules(includedModules: Set<String>) {
96+
subprojects.forEach { subproject ->
97+
if (subproject.name in includedModules) {
98+
dependencies.add("kover", subproject)
99+
}
100+
}
101+
}
102+
103+
internal fun Project.configureCoverageModule() {
104+
val coverageOptions = requireStreamProjectExtension().coverage
105+
106+
// Only configure coverage for included modules
107+
if (name !in coverageOptions.includedModules.get()) {
108+
return
109+
}
110+
111+
pluginManager.apply("org.sonarqube")
112+
113+
// Configure Android test coverage if this is an Android module
114+
pluginManager.withPlugin("com.android.library") { configureAndroid<LibraryExtension>() }
115+
pluginManager.withPlugin("com.android.application") { configureAndroid<ApplicationExtension>() }
116+
117+
configureKover(coverageOptions, isRoot = false)
118+
registerModuleCoverageTask()
119+
}
120+
121+
private fun Project.registerAggregatedCoverageTask(includedModules: Set<String>) {
122+
tasks.register(KoverConstants.TEST_TASK) {
123+
group = "verification"
124+
description = "Run all tests in all modules and generate merged coverage report"
125+
126+
// Depend on all module-specific testCoverage tasks
127+
val coverageModuleTasks =
128+
subprojects
129+
.filter { it.name in includedModules }
130+
.map { ":${it.name}:${KoverConstants.TEST_TASK}" }
131+
dependsOn(coverageModuleTasks)
132+
133+
finalizedBy(
134+
"koverXmlReport${KoverConstants.VARIANT_SUFFIX}",
135+
"koverHtmlReport${KoverConstants.VARIANT_SUFFIX}",
136+
)
137+
}
138+
}
139+
140+
private fun Project.registerModuleCoverageTask() {
141+
// Determine the appropriate test task based on module type and plugins
142+
val hasPaparazziPlugin = pluginManager.hasPlugin("app.cash.paparazzi")
143+
val hasAndroidPlugin =
144+
pluginManager.hasPlugin("com.android.library") ||
145+
pluginManager.hasPlugin("com.android.application")
146+
147+
val testTaskName =
148+
when {
149+
hasPaparazziPlugin -> "verifyPaparazziDebug"
150+
hasAndroidPlugin -> "testDebugUnitTest"
151+
else -> "test"
152+
}
153+
154+
tasks.register(KoverConstants.TEST_TASK) {
155+
group = "verification"
156+
description = "Run module-specific tests"
157+
dependsOn(testTaskName)
158+
}
159+
}
160+
161+
private inline fun <reified E : CommonExtension<*, *, *, *, *, *>> Project.configureAndroid() {
162+
extensions.configure<E> {
163+
buildTypes {
164+
getByName("debug") {
165+
enableUnitTestCoverage = true
166+
enableAndroidTestCoverage = true
167+
}
168+
}
169+
}
170+
}
171+
172+
private fun Project.configureKover(options: CoverageOptions, isRoot: Boolean) {
173+
pluginManager.apply("org.jetbrains.kotlinx.kover")
174+
175+
extensions.configure<KoverProjectExtension> {
176+
// Create custom variant in each project (including root) for coverage aggregation
177+
currentProject {
178+
createVariant(KoverConstants.VARIANT_NAME) {
179+
if (isRoot) {
180+
// Root variant is empty, it just aggregates from dependencies
181+
} else {
182+
add("jvm", "debug", optional = true)
183+
}
184+
}
185+
}
186+
187+
reports {
188+
verify.warningInsteadOfFailure.set(true)
189+
190+
filters.excludes {
191+
classes(KoverConstants.CLASS_EXCLUSIONS)
192+
classes(options.koverClassExclusions.get())
193+
194+
annotatedBy(*KoverConstants.ANNOTATION_EXCLUSIONS)
195+
}
196+
}
197+
}
198+
}
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)