diff --git a/.gitignore b/.gitignore index 0610dbd3..8f86d384 100644 --- a/.gitignore +++ b/.gitignore @@ -93,3 +93,9 @@ example/src/reproducers/local/* # Test coverage coverage/ +coverage-swift.lcov +coverage-kotlin.xml +coverage-kotlin.csv +coverage-kotlin-html/ +coverage.profdata +.jacoco/ diff --git a/ANDROID_KOTLIN_COVERAGE.md b/ANDROID_KOTLIN_COVERAGE.md new file mode 100644 index 00000000..eb694ea3 --- /dev/null +++ b/ANDROID_KOTLIN_COVERAGE.md @@ -0,0 +1,278 @@ +# Android Kotlin Code Coverage for React Native Libraries + +This documents how to collect Kotlin/Java code coverage from a React Native library running on an Android emulator, driven by external JS tests (not Android instrumented tests). This was implemented in [rive-app/rive-nitro-react-native#264](https://github.com/rive-app/rive-nitro-react-native/pull/264) and is intended as a reference for implementing generic Android coverage support in `react-native-harness`. + +## Why this is hard (and what doesn't work) + +### `enableAndroidTestCoverage` does NOT instrument the regular APK + +The obvious approach — setting `enableAndroidTestCoverage = true` in `build.gradle` — **only instruments classes for the `androidTest` APK variant**. When you build with `assembleDebug`, the classes in the APK have zero JaCoCo probes. You can verify this: + +```bash +# This class will NOT have $jacocoInit — no probes +javap -p android/build/tmp/kotlin-classes/debug/com/example/MyClass.class | grep jacoco +# (empty output) +``` + +The JaCoCo agent runtime gets loaded, and `RT.getAgent()` works, but since no bytecode has probes inserted, the execution data is empty. You end up with 0% coverage on everything except the coverage helper itself. + +This applies to both the library module and the app module — `enableAndroidTestCoverage` on either one only affects the `androidTest` build variant. + +### `-javaagent` doesn't work on Android + +JaCoCo's runtime instrumentation mode requires a JVM. Android uses ART, which does not support Java agents. This is a dead end. + +### Kover / IntelliJ agent doesn't work on Android + +JetBrains' Kover wraps JaCoCo or IntelliJ's coverage agent, but the IntelliJ agent only works on JVM, not ART. And Kover's JaCoCo mode has the same limitation — it's designed for `./gradlew test` (host JVM), not for on-device code. + +## What works: JaCoCo offline instrumentation + +The solution is **offline instrumentation** — rewriting `.class` files with JaCoCo probes after Kotlin compilation but before DEX-ing. The instrumented classes contain probe arrays that record execution at runtime. The JaCoCo agent runtime library provides the `Offline` class that manages the probe data in memory. + +### The four pieces + +#### 1. Gradle: offline-instrument class files after compilation + +Add a `doLast` to `compileDebugKotlin` that: +1. Saves a copy of the original (uninstrumented) classes (needed for report generation later) +2. Runs `org.jacoco.ant.InstrumentTask` to instrument the classes +3. Replaces the originals with instrumented versions + +The instrumented classes must be written to a **separate temp directory** first, then copied back. Writing in-place corrupts files because JaCoCo reads and writes to the same location simultaneously. + +```groovy +configurations { + jacocoAnt +} + +dependencies { + // The runtime jar provides org.jacoco.agent.rt.RT for the instrumented code + implementation "org.jacoco:org.jacoco.agent:0.8.12:runtime" + jacocoAnt "org.jacoco:org.jacoco.ant:0.8.12" +} + +afterEvaluate { + def classesDir = file("${buildDir}/tmp/kotlin-classes/debug") + + tasks.named("compileDebugKotlin").configure { + doLast { + if (!classesDir.exists()) return + + // Save originals for report generation + def origDir = file("${buildDir}/jacoco-original-classes") + if (origDir.exists()) origDir.deleteDir() + ant.copy(todir: origDir) { fileset(dir: classesDir) } + + // Instrument to temp dir, then replace + def instrumentedDir = file("${buildDir}/tmp/jacoco-instrumented") + if (instrumentedDir.exists()) instrumentedDir.deleteDir() + instrumentedDir.mkdirs() + + ant.taskdef( + name: 'instrument', + classname: 'org.jacoco.ant.InstrumentTask', + classpath: configurations.jacocoAnt.asPath + ) + ant.instrument(destdir: instrumentedDir) { + fileset(dir: classesDir, includes: '**/*.class') + } + + ant.copy(todir: classesDir, overwrite: true) { + fileset(dir: instrumentedDir) + } + } + } +} +``` + +**Important**: `org.jacoco:org.jacoco.agent:0.8.12:runtime` (note the `:runtime` classifier) is the agent JAR without the agent bootstrap — just the `RT` and `Offline` classes that the instrumented bytecode calls. Without this dependency, the app crashes at runtime with `ClassNotFoundException: org.jacoco.agent.rt.internal_aeaf9ab.Offline`. + +#### 2. `jacoco-agent.properties` — prevent crash on read-only filesystem + +When offline-instrumented code first loads, JaCoCo's `Offline` class initializes the agent singleton. By default, the agent tries to write execution data to `jacoco.exec` in the current working directory. On Android, the cwd is `/` (root filesystem, read-only). This causes a fatal `FileNotFoundException: /jacoco.exec: open failed: EROFS (Read-only file system)`. + +The fix: place a `jacoco-agent.properties` file on the classpath with `output=none`. This tells the agent not to auto-dump — our helper handles dumping manually. + +``` +# android/src/main/resources/jacoco-agent.properties +output=none +``` + +This file is harmless in non-coverage builds (JaCoCo classes aren't loaded, so the file is never read). + +#### 3. Runtime flush helper — dump `.ec` files to app storage + +The JaCoCo agent accumulates coverage data in memory. You must explicitly flush it to a `.ec` file. The key API: + +```kotlin +val agent = Class.forName("org.jacoco.agent.rt.RT") + .getMethod("getAgent").invoke(null) +val bytes = agent.javaClass + .getMethod("getExecutionData", Boolean::class.javaPrimitiveType) + .invoke(agent, false) as ByteArray +File(context.filesDir, "coverage-${Process.myPid()}.ec").writeBytes(bytes) +``` + +Flush triggers: +- **1-second periodic timer** (background daemon thread) — most reliable, catches data even if the app is killed +- **Activity lifecycle** (`onActivityStopped`) — flushes when app goes to background +- Per-process filenames (`coverage-$pid.ec`) — the harness restarts the app for each test suite, so each process gets its own file + +**Important**: `am force-stop` kills the process immediately without calling any lifecycle callbacks or giving the timer a chance to fire. The 1s timer ensures data is flushed at most 1 second before any kill. If the process is killed within 1 second of starting, that test run's data is lost. + +#### 4. Bootstrap — start the helper early + +Use a `ContentProvider` to bootstrap the helper before any Activity starts. ContentProviders run during `Application.onCreate`, which is the earliest point with a `Context` on Android (equivalent of iOS's ObjC `+load`). + +```kotlin +class CoverageInitProvider : ContentProvider() { + override fun onCreate(): Boolean { + context?.let { CoverageHelper.setup(it) } + return true + } + // ... stub all other abstract methods +} +``` + +Register in `AndroidManifest.xml`: +```xml + +``` + +Use a `BuildConfig` boolean flag to no-op in non-coverage builds: +```kotlin +fun setup(context: Context) { + if (!BuildConfig.COVERAGE_ENABLED) return + // ... +} +``` + +## Collecting coverage after tests + +### Pull `.ec` files from the emulator + +The harness restarts the app multiple times. Each restart creates a new `.ec` file. Use `tar` to pull all files reliably — `adb shell run-as` with `find` + `cat` in a pipe loses data due to buffering issues: + +```bash +adb shell am force-stop "$APP_ID" +sleep 2 # let final flush complete + +# tar is reliable across multiple files; find+cat in a pipe drops entries +adb shell run-as "$APP_ID" sh -c "'cd files && tar cf - *.ec 2>/dev/null'" \ + | tar xf - -C ./ec-files/ +``` + +### Merge and generate reports + +JaCoCo CLI merges multiple `.ec` files and generates reports. It needs the **original (uninstrumented)** class files — not the instrumented ones. + +```bash +# Merge all .ec files +java -jar jacococli.jar merge ec-files/*.ec --destfile merged.ec + +# Generate report — classfiles must be the ORIGINAL (uninstrumented) classes +java -jar jacococli.jar report merged.ec \ + --classfiles android/build/jacoco-original-classes \ + --sourcefiles android/src/main/java \ + --xml coverage.xml \ + --html coverage-html/ +``` + +**Class file mismatch warning**: The `.ec` data must match the exact build that produced the instrumented classes. If you rebuild without re-running tests, the `.ec` is stale and the report will be wrong. + +## Comparison with iOS coverage (PR #190) + +| Aspect | iOS (LLVM profiling) | Android (JaCoCo offline) | +|--------|---------------------|--------------------------| +| Instrumentation | Compiler flags (`-profile-generate -profile-coverage-mapping`) added to podspec | JaCoCo ant task rewrites `.class` files after compilation | +| Opt-in mechanism | Env var `RIVE_SWIFT_COVERAGE=1` before `pod install` | Gradle property `-PRive_KotlinCoverage=true` | +| Runtime agent | LLVM profiling runtime (built into the binary) | `org.jacoco:org.jacoco.agent:0.8.12:runtime` dependency | +| Data format | `.profraw` (one per process) | `.ec` (one per process) | +| Flush API | `__llvm_profile_write_file()` via `@_silgen_name` | `RT.getAgent().getExecutionData(false)` via reflection | +| Auto-dump config | N/A (LLVM doesn't auto-dump) | `jacoco-agent.properties` with `output=none` (prevents `/jacoco.exec` crash) | +| Bootstrap | ObjC `+load` in `CoverageSetup.m` | `ContentProvider.onCreate()` | +| Report tool | `xcrun llvm-cov export` → lcov natively | `jacococli.jar report` → XML/HTML (lcov needs a converter) | +| Class file requirement | N/A (symbols are in the binary) | Report needs the **original** (uninstrumented) `.class` files | +| Gotcha: in-place instrumentation | N/A | Must instrument to temp dir then copy back; in-place corrupts files | +| Gotcha: read-only filesystem | N/A | JaCoCo agent defaults to writing `/jacoco.exec` on root FS → crash | +| Gotcha: `enableAndroidTestCoverage` | N/A | Only instruments the `androidTest` variant, NOT the regular debug APK | + +## For react-native-harness implementation + +The harness uses a Gradle init script approach (`harness-coverage-init.gradle`) that injects coverage into any library module without modifying its `build.gradle`. This was tested against rive-nitro-react-native and the following bugs were found and fixed: + +### Bug 1: `afterEvaluate` inside `projectsEvaluated` crashes + +The init script originally used `gradle.projectsEvaluated { project.afterEvaluate { ... } }`. This crashes because `projectsEvaluated` runs after all projects are evaluated — calling `afterEvaluate` at that point fails with "Cannot run Project.afterEvaluate when the project is already evaluated." + +**Fix**: Restructure to `gradle.allprojects { project.afterEvaluate { ... } }`. The `allprojects` callback registers during configuration, so `afterEvaluate` is still valid at that point. + +### Bug 2: `jacocoCli` configuration resolves to multiple files + +`project.configurations.jacocoCli.singleFile` throws "contains more than one file" because the `nodeps` classifier JAR still pulls transitive dependencies. + +**Fix**: Set `transitive = false` on the configuration: +```groovy +def jacocoCliConf = project.configurations.maybeCreate('jacocoCli') +jacocoCliConf.transitive = false +``` + +### Bug 3: Compile tasks not found — `findByName` returns null + +AGP and the React Native Gradle plugin register `compileDebugKotlin` lazily via the task configuration avoidance API. `findByName()` doesn't materialize lazy tasks, so it returns `null` even though the task exists. + +**Fix**: Use `tasks.configureEach` with name matching instead — this applies to both already-materialized and not-yet-materialized tasks: +```groovy +project.tasks.configureEach { task -> + if (task.name == 'compileDebugKotlin') { + task.doLast { instrumentClasses(kotlinClasses, 'kotlin') } + } +} +``` + +### Bug 4: `BuildConfig.COVERAGE_ENABLED` doesn't compile + +`CoverageHelper.kt` is in package `com.harness.coverage`, but `BuildConfig` is generated in the target module's namespace (e.g., `com.rive`). An unqualified `BuildConfig` reference resolves to `com.harness.coverage.BuildConfig` which doesn't exist. + +**Fix**: Remove the `BuildConfig` check entirely. Coverage is already opt-in at build time (the init script is only applied when coverage is requested). The JaCoCo agent availability check (`Class.forName("org.jacoco.agent.rt.RT")`) is sufficient as a runtime guard. + +### Bug 5: Source sets added too late — coverage helper classes not compiled + +Adding `kotlin.srcDirs` to the source set inside `gradle.projectsEvaluated` is too late — the compile task has already resolved its inputs. The coverage helper Kotlin sources are ignored, resulting in `ClassNotFoundException` at runtime for `CoverageInitProvider`. + +**Fix**: This was fixed by the same restructure as Bug 1 — moving to `gradle.allprojects` + `afterEvaluate` ensures source sets are configured before compile tasks finalize. + +### Summary of init script structure + +The correct hook point structure for a Gradle init script that injects sources and instruments classes is: + +``` +gradle.allprojects { project -> // runs during configuration + project.afterEvaluate { // runs after build.gradle is processed + // Add dependencies, source sets, (before compile tasks run) + // BuildConfig fields here + + project.tasks.configureEach { // catches lazily-registered tasks + if (task.name == '...') { + task.doLast { ... } // runs after compilation + } + } + } +} +``` + +Do **not** use `gradle.projectsEvaluated` for anything that modifies source sets, dependencies, or BuildConfig — it's too late. + +## Reference implementation + +See the full working implementation in this repo: +- [`android/build.gradle`](android/build.gradle) — Gradle offline instrumentation setup +- [`android/src/main/java/com/rive/CoverageHelper.kt`](android/src/main/java/com/rive/CoverageHelper.kt) — Runtime flush helper +- [`android/src/main/java/com/rive/CoverageInitProvider.kt`](android/src/main/java/com/rive/CoverageInitProvider.kt) — ContentProvider bootstrap +- [`android/src/main/resources/jacoco-agent.properties`](android/src/main/resources/jacoco-agent.properties) — Agent config +- [`android/src/main/AndroidManifest.xml`](android/src/main/AndroidManifest.xml) — ContentProvider registration +- [`scripts/android-coverage.sh`](scripts/android-coverage.sh) — Collection and report script diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75f29e77..082ee82e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,6 +125,29 @@ The `package.json` file contains various scripts for common tasks: - `yarn example android`: run the example app on Android. - `yarn example ios`: run the example app on iOS. +### Kotlin code coverage + +You can measure which Kotlin code in `android/` gets exercised by the harness tests. This uses JaCoCo bytecode instrumentation and is opt-in. + +```sh +# 1. Build the example app with coverage enabled +cd example/android && ./gradlew assembleDebug -PRive_KotlinCoverage=true && cd ../.. + +# 2. Run harness tests +yarn test:harness:android + +# 3. Extract coverage and print a summary +bash scripts/android-coverage.sh +``` + +This produces `coverage-kotlin.xml` and `coverage-kotlin-html/` in the repo root. To view the HTML report: + +```sh +open coverage-kotlin-html/index.html +``` + +To go back to normal (non-coverage) builds, just build without the `-PRive_KotlinCoverage=true` flag. + ### Sending a pull request When you're sending a pull request: diff --git a/android/build.gradle b/android/build.gradle index 5cf1cd50..37060243 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -35,6 +35,9 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["Rive_" + name]).toInteger() } +def kotlinCoverage = project.hasProperty('Rive_KotlinCoverage') ? + project.property('Rive_KotlinCoverage').toBoolean() : false + android { namespace "com.rive" @@ -47,6 +50,7 @@ android { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") buildConfigField "boolean", "RIVE_SKIP_SETUP", "$skipSetup" + buildConfigField "boolean", "RIVE_KOTLIN_COVERAGE", "$kotlinCoverage" externalNativeBuild { cmake { @@ -102,6 +106,10 @@ android { } } + if (kotlinCoverage) { + println "@rive-app/react-native: Kotlin code coverage ENABLED" + } + lintOptions { disable "GradleCompatible" } @@ -149,10 +157,59 @@ def getRiveAndroidVersion() { def riveAndroidVersion = getRiveAndroidVersion() println "@rive-app/react-native: Rive Android SDK ${riveAndroidVersion}" +configurations { + jacocoAnt +} + dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "app.rive:rive-android:${riveAndroidVersion}" implementation project(":react-native-nitro-modules") + + if (kotlinCoverage) { + implementation "org.jacoco:org.jacoco.agent:0.8.12:runtime" + jacocoAnt "org.jacoco:org.jacoco.ant:0.8.12" + } +} + +if (kotlinCoverage) { + afterEvaluate { + def classesDir = file("${buildDir}/tmp/kotlin-classes/debug") + + tasks.named("compileDebugKotlin").configure { + doLast { + if (!classesDir.exists()) return + + // Save a copy of the original (uninstrumented) classes for report generation + def origDir = file("${buildDir}/jacoco-original-classes") + if (origDir.exists()) origDir.deleteDir() + ant.copy(todir: origDir) { + fileset(dir: classesDir) + } + + // Offline-instrument class files: write to temp dir, then replace originals + def instrumentedDir = file("${buildDir}/tmp/jacoco-instrumented") + if (instrumentedDir.exists()) instrumentedDir.deleteDir() + instrumentedDir.mkdirs() + + ant.taskdef( + name: 'instrument', + classname: 'org.jacoco.ant.InstrumentTask', + classpath: configurations.jacocoAnt.asPath + ) + ant.instrument(destdir: instrumentedDir) { + fileset(dir: classesDir, includes: '**/*.class') + } + + // Replace original classes with instrumented ones + ant.copy(todir: classesDir, overwrite: true) { + fileset(dir: instrumentedDir) + } + + println "[Coverage] Instrumented classes in ${classesDir}" + } + } + } } diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index c91836ff..bdf31f2f 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1,3 +1,8 @@ - + + + diff --git a/android/src/main/java/com/rive/CoverageHelper.kt b/android/src/main/java/com/rive/CoverageHelper.kt new file mode 100644 index 00000000..e1d189d9 --- /dev/null +++ b/android/src/main/java/com/rive/CoverageHelper.kt @@ -0,0 +1,76 @@ +package com.rive + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.util.Log +import java.io.File + +object CoverageHelper { + private const val TAG = "Coverage" + private var ecFile: File? = null + private var timer: java.util.Timer? = null + + fun setup(context: Context) { + if (!BuildConfig.RIVE_KOTLIN_COVERAGE) return + + val agent = + try { + Class + .forName("org.jacoco.agent.rt.RT") + .getMethod("getAgent") + .invoke(null) + } catch (e: Exception) { + Log.w(TAG, "JaCoCo agent not available — was the app built with coverage?", e) + return + } + + val pid = android.os.Process.myPid() + ecFile = File(context.filesDir, "coverage-$pid.ec") + + val app = context.applicationContext as? Application + app?.registerActivityLifecycleCallbacks( + object : Application.ActivityLifecycleCallbacks { + override fun onActivityStopped(activity: Activity) = flush() + override fun onActivityCreated(a: Activity, b: Bundle?) {} + override fun onActivityStarted(a: Activity) {} + override fun onActivityResumed(a: Activity) {} + override fun onActivityPaused(a: Activity) {} + override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {} + override fun onActivityDestroyed(a: Activity) {} + }, + ) + + timer = + java.util.Timer("CoverageFlush", true).also { + it.scheduleAtFixedRate( + object : java.util.TimerTask() { + override fun run() = flush() + }, + 1000L, + 1000L, + ) + } + + Log.i(TAG, "pid=$pid, flushing to ${ecFile?.absolutePath}") + } + + fun flush() { + val file = ecFile ?: return + try { + val agent = + Class + .forName("org.jacoco.agent.rt.RT") + .getMethod("getAgent") + .invoke(null) + val bytes = + agent.javaClass + .getMethod("getExecutionData", Boolean::class.javaPrimitiveType) + .invoke(agent, false) as ByteArray + file.writeBytes(bytes) + } catch (e: Exception) { + Log.w(TAG, "Failed to flush coverage data", e) + } + } +} diff --git a/android/src/main/java/com/rive/CoverageInitProvider.kt b/android/src/main/java/com/rive/CoverageInitProvider.kt new file mode 100644 index 00000000..8d83dba1 --- /dev/null +++ b/android/src/main/java/com/rive/CoverageInitProvider.kt @@ -0,0 +1,42 @@ +package com.rive + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +class CoverageInitProvider : ContentProvider() { + override fun onCreate(): Boolean { + val ctx = context ?: return true + CoverageHelper.setup(ctx) + return true + } + + override fun query( + u: Uri, + p: Array?, + s: String?, + a: Array?, + o: String?, + ): Cursor? = null + + override fun getType(uri: Uri): String? = null + + override fun insert( + uri: Uri, + values: ContentValues?, + ): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array?, + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array?, + ): Int = 0 +} diff --git a/android/src/main/resources/jacoco-agent.properties b/android/src/main/resources/jacoco-agent.properties new file mode 100644 index 00000000..52c4b253 --- /dev/null +++ b/android/src/main/resources/jacoco-agent.properties @@ -0,0 +1 @@ +output=none diff --git a/scripts/android-coverage.sh b/scripts/android-coverage.sh new file mode 100755 index 00000000..3a1aa51d --- /dev/null +++ b/scripts/android-coverage.sh @@ -0,0 +1,86 @@ +#!/bin/bash +set -euo pipefail + +APP_ID="${1:-rive.example}" +REPO_ROOT=$(git rev-parse --show-toplevel) +JACOCO_VERSION="${JACOCO_VERSION:-0.8.12}" +JACOCO_CLI="$REPO_ROOT/.jacoco/jacococli.jar" + +# Download JaCoCo CLI if not cached +if [ ! -f "$JACOCO_CLI" ]; then + echo "Downloading JaCoCo CLI $JACOCO_VERSION..." + mkdir -p "$REPO_ROOT/.jacoco" + curl -sL "https://github.com/jacoco/jacoco/releases/download/v${JACOCO_VERSION}/jacoco-${JACOCO_VERSION}.zip" \ + -o /tmp/jacoco.zip + unzip -oq /tmp/jacoco.zip "lib/jacococli.jar" -d /tmp/jacoco + mv /tmp/jacoco/lib/jacococli.jar "$JACOCO_CLI" + rm -rf /tmp/jacoco /tmp/jacoco.zip +fi + +# Force-stop the app to trigger activity lifecycle flush, then wait for data +adb shell am force-stop "$APP_ID" 2>/dev/null || true +sleep 2 + +# Pull all .ec files from the app's internal storage +EC_DIR="$REPO_ROOT/.jacoco/ec" +rm -rf "$EC_DIR" +mkdir -p "$EC_DIR" + +# Use tar to reliably pull all .ec files (avoids adb shell + pipe issues) +adb shell run-as "$APP_ID" sh -c "'cd files && tar cf - *.ec 2>/dev/null'" | tar xf - -C "$EC_DIR" 2>/dev/null || true + +EC_COUNT=$(find "$EC_DIR" -name "*.ec" 2>/dev/null | wc -l | tr -d ' ') +[ "$EC_COUNT" -gt 0 ] || { echo "No .ec files found in $APP_ID internal storage"; exit 1; } +echo "Found $EC_COUNT .ec file(s)" + +# Merge .ec files +MERGED_EC="$REPO_ROOT/.jacoco/merged.ec" +java -jar "$JACOCO_CLI" merge "$EC_DIR"/*.ec --destfile "$MERGED_EC" + +# Use the original (uninstrumented) class files saved during the build +LIB_CLASSES="$REPO_ROOT/android/build/jacoco-original-classes" +if [ ! -d "$LIB_CLASSES" ]; then + echo "Original class files not found at $LIB_CLASSES" + echo "Was the library built with -PRive_KotlinCoverage=true?" + exit 1 +fi +echo "Using class files: $LIB_CLASSES" + +LIB_SOURCES="$REPO_ROOT/android/src/main/java" + +# Generate XML report +java -jar "$JACOCO_CLI" report "$MERGED_EC" \ + --classfiles "$LIB_CLASSES" \ + --sourcefiles "$LIB_SOURCES" \ + --xml "$REPO_ROOT/coverage-kotlin.xml" + +# Generate CSV for quick summary +java -jar "$JACOCO_CLI" report "$MERGED_EC" \ + --classfiles "$LIB_CLASSES" \ + --sourcefiles "$LIB_SOURCES" \ + --csv "$REPO_ROOT/coverage-kotlin.csv" + +# Generate HTML report +java -jar "$JACOCO_CLI" report "$MERGED_EC" \ + --classfiles "$LIB_CLASSES" \ + --sourcefiles "$LIB_SOURCES" \ + --html "$REPO_ROOT/coverage-kotlin-html" + +# Print summary from CSV +echo "" +echo "=== Kotlin Coverage Summary ===" +if [ -f "$REPO_ROOT/coverage-kotlin.csv" ]; then + # CSV columns: GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,...,LINE_MISSED,LINE_COVERED,... + tail -n +2 "$REPO_ROOT/coverage-kotlin.csv" | awk -F',' ' + { line_missed += $8; line_covered += $9 } + END { + total = line_missed + line_covered + if (total > 0) printf "Lines: %d/%d (%.1f%%)\n", line_covered, total, line_covered * 100.0 / total + else print "No coverage data" + } + ' +fi + +echo "" +echo "→ coverage-kotlin.xml" +echo "→ coverage-kotlin-html/index.html"