diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d05abc5..c28522f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -89,6 +89,21 @@ jobs: path: app/build/reports/tests/testDebugUnitTest/ if-no-files-found: warn + # JaCoCo XML + HTML coverage for the JVM unit tests (issue #192). Reporting only — no + # threshold gate yet, so a coverage regression does not fail CI (a jacocoTestCoverageVerification + # gate is a natural follow-up once there's a baseline to enforce). + - name: Generate JaCoCo coverage report + if: ${{ !cancelled() }} + run: ./gradlew :app:jacocoTestReport --stacktrace + + - name: Upload coverage report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: jacoco-coverage-report + path: app/build/reports/jacoco/jacocoTestReport/ + if-no-files-found: warn + static-analysis: name: Static analysis runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 18a3db8..8f8ed87 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ Use a **JDK 17–21** for the Gradle daemon. AGP 9.2 does **not** support JDK 25 ./gradlew :app:testDebugUnitTest # JVM unit tests ./gradlew :app:lintDebug # Android lint ./gradlew :app:ktlintCheck :app:detekt # static analysis (CI's "Static analysis" gate) +./gradlew :app:jacocoTestReport # JVM unit-test coverage (XML+HTML under app/build/reports/jacoco/) # single unit test: ./gradlew :app:testDebugUnitTest --tests "org.libremail.data.SomeClassTest" ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21858a5..b4d6630 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later +import org.gradle.testing.jacoco.tasks.JacocoReport import java.util.Properties plugins { @@ -9,6 +10,11 @@ plugins { id("org.jetbrains.kotlin.plugin.compose") id("com.google.devtools.ksp") id("com.google.dagger.hilt.android") + // JaCoCo (Gradle built-in) — unit-test code-coverage reporting (issue #192). The base `jacoco` + // plugin auto-instruments the JVM `testDebugUnitTest` task; the jacocoTestReport task below turns + // its exec data into XML + HTML. AGP-9-safe: it does NOT apply org.jetbrains.kotlin.android (which + // ClassCastExceptions against AGP 9's built-in-Kotlin DSL — see CLAUDE.md) and touches no variant DSL. + jacoco // Lint/format — resolved from the Gradle Plugin Portal (not the buildscript classpath). alias(libs.plugins.ktlint) alias(libs.plugins.detekt) @@ -172,6 +178,85 @@ detekt { config.setFrom(rootProject.file("config/detekt/detekt.yml")) } +// Pin a modern JaCoCo (version catalog) so the coverage agent understands Kotlin 2.4.0 bytecode +// on JDK 21. +jacoco { + toolVersion = libs.versions.jacoco.get() +} + +// Unit-test coverage report (issue #192). Reads the exec data the base `jacoco` plugin records for +// the JVM `testDebugUnitTest` task, mapped against the debug variant's compiled Kotlin classes and +// the hand-written main sources. Produces machine-readable XML + human-readable HTML under +// build/reports/jacoco/jacocoTestReport/. Instrumented/E2E coverage is out of scope (issue #192). +tasks.register("jacocoTestReport") { + // Ensure the unit tests (and thus their coverage exec data) have run first. + dependsOn("testDebugUnitTest") + group = "verification" + description = "Generates JaCoCo XML + HTML coverage for the debug JVM unit tests." + + reports { + xml.required.set(true) + html.required.set(true) + } + + // Strip generated code from the denominator so the % reflects hand-written Kotlin. Verified + // against an actual compileDebugKotlin output tree: Room's KSP-generated `_Impl` DAOs/database + // and the Compose compiler's per-file ComposableSingletons holders are the only generated code + // that actually lands in classDirectories below (Room's KSP output is added as an extra Kotlin + // source root on the *same* compile task, so it comes out the same door as hand-written code). + // Hilt/Dagger's generated Java (Hilt_*, Dagger*_HiltComponents*, *_GeneratedInjector, *_Factory, + // *_MembersInjector, hilt_aggregated_deps) and AGP's BuildConfig/R/Manifest are compiled by a + // separate javac task (hiltJavaCompileDebug / compileDebugJavaWithJavac) into a directory this + // report never reads, so those patterns are conventional belt-and-suspenders in case that ever + // changes. DataBinding isn't enabled in this module (no buildFeatures.dataBinding/viewBinding), + // so there's nothing generated for it to exclude; if it's turned on later, add "**/BR.class", + // "**/DataBinderMapperImpl*.class" and "**/*Binding.class". + // + // Deliberately NOT excluded: Kotlin's own `$$inlined$` synthetic classes (e.g. for + // `Flow.map { ... }` in the repositories) — those hold real hand-written transform logic, not + // generated boilerplate, so stripping them would silently shrink the measured surface. + val generated = listOf( + "**/R.class", + "**/R\$*.class", + "**/BuildConfig.*", + "**/Manifest*.*", + "**/Hilt_*.class", + "**/Dagger*.class", + "**/*_Hilt*", + "**/*_GeneratedInjector.class", + "**/hilt_aggregated_deps/**", + "**/dagger/**", + "**/*_Factory*", + "**/*_MembersInjector*", + "**/*_Provide*", + "**/*_Impl*", + "**/ComposableSingletons*", + ) + + // Classes = the debug variant's compiled Kotlin (AGP 9 built-in Kotlin output). All hand-written + // code here is Kotlin, so the javac output (purely Hilt/Dagger/BuildConfig generated) is omitted. + val debugKotlinClasses = layout.buildDirectory.dir( + "intermediates/built_in_kotlinc/debug/compileDebugKotlin/classes", + ) + classDirectories.setFrom( + fileTree(debugKotlinClasses) { exclude(generated) }, + ) + + // Sources = hand-written main Kotlin. + sourceDirectories.setFrom(files("src/main/kotlin")) + + // Exec data written by the instrumented testDebugUnitTest task. Accept the base `jacoco` plugin's + // default location and AGP's enableUnitTestCoverage location so the wiring is robust either way. + executionData.setFrom( + fileTree(layout.buildDirectory) { + include( + "jacoco/testDebugUnitTest.exec", + "outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", + ) + }, + ) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20e92fc..d4e236c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,9 @@ errorprone = "2.50.0" # detekt 2.0 is the only line with Gradle 9 support (stable 1.23.x tops out at Gradle 8.12). ktlint = "14.2.0" detekt = "2.0.0-alpha.5" +# JaCoCo coverage agent/report tool (issue #192). Pinned above Gradle's bundled default so the +# coverage agent understands Kotlin 2.4.0 bytecode on JDK 21. +jacoco = "0.8.13" [libraries] # AndroidX core / lifecycle / activity / navigation