From b2803a905fb9b5cd920692b1c3c066dcd2d471ad Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 15 Sep 2026 20:49:39 +0530 Subject: [PATCH 1/2] refactor(build): Harden JaCoCo coverage report configuration `JacocoReport` skips rather than fails when its execution data is missing, so a wrong or unwritten `.exec` path publishes an empty report instead of breaking the build. Add a `verifyJacocoExecutionData` task that fails when no execution data was produced. It has to be a separate task because a check inside the report task would never run once that task is skipped. Resolve the execution data through a `fileTree` over the known `.exec` locations rather than a single hardcoded path. Where AGP writes unit test coverage depends on whether the `jacoco` plugin is applied before or after AGP: applying it first makes AGP redirect output to `outputs/unit_test_code_coverage` instead of the Gradle JaCoCo plugin's `build/jacoco` default. A file tree only matches files that exist, so it picks up whichever path is actually written and keeps working if that ordering changes. Rename `jacocoLocalDebugUnitTestReport` to `jacocoTestReport`. The task is registered for KMP and Android KMP modules too, where it runs `jvmTest` or `testAndroidHostTest` and has nothing to do with a local debug variant. Move the JaCoCo version into the version catalog alongside the other pinned tool versions. --- .github/workflows/ci.yml | 2 +- config/jacoco/jacoco.gradle | 40 +++++++++++++++++++++++++++++-------- gradle/libs.versions.toml | 1 + 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db02d9e7f5..b3bdd129e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: run: ./gradlew testLocalDebugUnitTest jvmTest testAndroidHostTest - name: Generate codecov report - run: ./gradlew jacocoLocalDebugUnitTestReport + run: ./gradlew jacocoTestReport - name: Upload coverage to Codecov if: github.actor != 'dependabot[bot]' diff --git a/config/jacoco/jacoco.gradle b/config/jacoco/jacoco.gradle index 831d27b071..2a9319fdd2 100644 --- a/config/jacoco/jacoco.gradle +++ b/config/jacoco/jacoco.gradle @@ -17,7 +17,7 @@ apply plugin: 'jacoco' jacoco { - toolVersion = "0.8.13" + toolVersion = libs.versions.jacocoVersion.get() } tasks.withType(Test).configureEach { @@ -48,29 +48,53 @@ def excludesList = [ def testTaskName def classDirs def sourceDirs -def execData +// AGP redirects coverage to `outputs/unit_test_code_coverage` when the `jacoco` plugin is applied +// before it, otherwise the Gradle plugin's `build/jacoco` default wins. Accept both. +def execDataCandidates if (isAndroidModule) { testTaskName = 'testLocalDebugUnitTest' classDirs = 'build/intermediates/classes/localDebug/transformLocalDebugClassesWithAsm/dirs/org/groundplatform/android' sourceDirs = ['src/main/java/org/groundplatform/android'] - execData = 'build/jacoco/testLocalDebugUnitTest.exec' + execDataCandidates = [ + 'jacoco/testLocalDebugUnitTest.exec', + 'outputs/unit_test_code_coverage/localDebugUnitTest/testLocalDebugUnitTest.exec', + ] } else if (isAndroidKmpModule) { testTaskName = 'testAndroidHostTest' classDirs = 'build/classes/kotlin/android/main' sourceDirs = ['src/commonMain/kotlin', 'src/androidMain/kotlin'] - execData = 'build/jacoco/testAndroidHostTest.exec' + execDataCandidates = ['jacoco/testAndroidHostTest.exec'] } else if (isKmpModule) { testTaskName = 'jvmTest' classDirs = 'build/classes/kotlin/jvm/main' sourceDirs = ['src/commonMain/kotlin'] - execData = 'build/jacoco/jvmTest.exec' + execDataCandidates = ['jacoco/jvmTest.exec'] } -tasks.register('jacocoLocalDebugUnitTestReport', JacocoReport) { +// A `fileTree` matches only files that exist, so this resolves to whichever candidate was written. +def execData = fileTree(dir: 'build', includes: execDataCandidates) + +// `JacocoReport` skips rather than fails on missing execution data, silently publishing an empty +// report. A check inside that task would never run, so guard from a separate one. +def verifyExecutionData = tasks.register('verifyJacocoExecutionData') { dependsOn tasks.named(testTaskName) + group = "Verification" + description = "Fails if '$testTaskName' produced no JaCoCo execution data." + doLast { + if (execData.empty) { + throw new GradleException( + "No JaCoCo execution data found under 'build' at any of $execDataCandidates. " + + "The coverage report would be silently empty. Verify where '$testTaskName' " + + "writes its .exec file.") + } + } +} + +tasks.register('jacocoTestReport', JacocoReport) { + dependsOn verifyExecutionData group = "Reporting" - description = "Run tests and generate coverage reports" + description = "Runs '$testTaskName' and generates a JaCoCo coverage report." reports { csv.required = false xml.required = true @@ -78,5 +102,5 @@ tasks.register('jacocoLocalDebugUnitTestReport', JacocoReport) { } classDirectories.from = fileTree(dir: classDirs, excludes: excludesList) sourceDirectories.from = files(sourceDirs) - executionData.from = files(execData) + executionData.from = execData } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 931e9c524f..847bd066ae 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,7 @@ groundPlatformVersion = "259e72a" gsonVersion = "2.14.0" hiltJetpackVersion = "1.4.0" hiltVersion = "2.60.1" +jacocoVersion = "0.8.13" jvmToolchainVersion = "17" jsonVersion = "20260814" junitKtx = "1.3.0" From c647355bc6ff2e1a5d82e72899a624c1e1d2a46e Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 15 Sep 2026 20:51:29 +0530 Subject: [PATCH 2/2] refactor(build): Limit JaCoCo excludes to generated code The exclude list had grown to cover hand-written classes alongside generated ones, which hid real gaps in the coverage report. Measuring a report built with excludes disabled showed six patterns matching only hand-written Kotlin: `migration/*`, `firebase/base/*`, `firebase/schema/*Reference*`, `FirebaseStorageManager*`, `FirestoreDataStore*` and `LocationSharedFlowCallback*`. Drop them so the 240 lines they hid, 61 of which are already covered, are measured like any other source. Replace `**/*Module*` with `**/di/**`. The old pattern matched 159 classes on substring alone and would have hidden any hand-written class merely named "...Module..." anywhere in the tree. Every one of its non-generated matches lives under `di/`, and the generated `..._HiltModule` classes outside it stay excluded via `**/*Hilt*`. `**/di/**` also picks up `di/coroutines`, which the old single-segment matching missed. Cover the generated code that was leaking into the report in the other direction. Mapping every source file under `app/build/generated` against the report found Room's auto-migration implementations being measured, so broaden `**/LocalDatabase_Impl*` to `**/LocalDatabase_*`. Add `**/*_MembersInjector*`, `**/*_GeneratedInjector*`, `**/*_AssistedFactory*` and `**/*_ComponentTreeDeps*` for the remaining Dagger artifacts; those carry no lines, but leaving them in contradicts what this list is for. All ten generated source roots are now fully excluded. Reported coverage for `:app` moves from 78.46% to 77.44%, reflecting previously hidden code rather than any change in what the tests exercise. --- config/jacoco/jacoco.gradle | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/config/jacoco/jacoco.gradle b/config/jacoco/jacoco.gradle index 2a9319fdd2..9253f3d30d 100644 --- a/config/jacoco/jacoco.gradle +++ b/config/jacoco/jacoco.gradle @@ -28,22 +28,26 @@ tasks.withType(Test).configureEach { def isAndroidModule = plugins.hasPlugin('com.android.application') || plugins.hasPlugin('com.android.library') def isAndroidKmpModule = plugins.hasPlugin('com.android.kotlin.multiplatform.library') def isKmpModule = plugins.hasPlugin('org.jetbrains.kotlin.multiplatform') +// Generated code only: excluding hand-written classes would hide real gaps. Every pattern here +// matches `:app` classes only; no other module generates code of these kinds. def excludesList = [ '**/databinding/*', '**/proto/*', - '**/migration/*', + // Room. `LocalDatabase_*` covers both the database impl and the generated auto-migrations. '**/local/room/dao/*', + '**/LocalDatabase_*', + // Hilt/Dagger. `**/di/**` rather than `**/*Module*`, which also matched hand-written classes + // merely named "...Module...". '**/*_Factory*', + '**/*_MembersInjector*', + '**/*_GeneratedInjector*', + '**/*_AssistedFactory*', + '**/*_ComponentTreeDeps*', '**/*Hilt*', + '**/di/**', + // Navigation Safe Args. '**/*Args*', '**/*Directions*', - '**/LocalDatabase_Impl*', - '**/*Module*', - '**/data/remote/firebase/base/*', - '**/data/remote/firebase/schema/*Reference*', - '**/data/remote/firebase/FirebaseStorageManager*', - '**/data/remote/firebase/FirestoreDataStore*', - '**/system/channel/LocationSharedFlowCallback*', ] def testTaskName def classDirs