diff --git a/.github/workflows/coverage-active.yml b/.github/workflows/coverage-active.yml new file mode 100644 index 000000000..21c46c974 --- /dev/null +++ b/.github/workflows/coverage-active.yml @@ -0,0 +1,156 @@ +name: Code Coverage + +on: + pull_request: + branches: [ "main" ] + +jobs: + coverage: + runs-on: ubuntu-latest + + permissions: + contents: read + pull-requests: write + checks: write + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'zulu' + cache: gradle + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Create local.properties + run: | + echo "sdk.dir=$ANDROID_HOME" > local.properties + echo "ndk.dir=$ANDROID_HOME/ndk-bundle" >> local.properties + + - name: Run unit tests + run: ./gradlew :source:testDebugUnitTest --stacktrace + + - name: Generate coverage report + run: ./gradlew :source:jacocoTestReport --stacktrace + + - name: Generate root coverage report + run: ./gradlew jacocoRootReport --stacktrace + + - name: Debug coverage files + run: | + echo "=== Looking for coverage files ===" + find . -name "*.exec" -type f | head -10 + find . -name "*.xml" -path "*/jacoco*" -type f | head -10 + echo "=== Checking specific paths ===" + ls -la build/reports/jacoco/jacocoRootReport/ || echo "Root report not found" + ls -la source/build/reports/jacoco/jacocoTestReport/ || echo "Module report not found" + + # Show a sample of the XML content if it exists + if [ -f "build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" ]; then + echo "=== Root XML content sample ===" + head -20 build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml + fi + + if [ -f "source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" ]; then + echo "=== Module XML content sample ===" + head -20 source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml + fi + + - name: Calculate coverage from XML + id: coverage + run: | + # Try to find the coverage XML file + COVERAGE_XML="" + if [ -f "build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" ]; then + COVERAGE_XML="build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" + elif [ -f "source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" ]; then + COVERAGE_XML="source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" + fi + + if [ -n "$COVERAGE_XML" ]; then + echo "coverage_file_exists=true" >> $GITHUB_OUTPUT + echo "coverage_xml_path=$COVERAGE_XML" >> $GITHUB_OUTPUT + + # Extract coverage percentages using grep and sed + INSTRUCTION_COVERAGE=$(grep -o 'instruction[^>]*covered="[^"]*"[^>]*missed="[^"]*"' "$COVERAGE_XML" | head -1 | sed -E 's/.*covered="([^"]*)".*missed="([^"]*)".*/\1 \2/' | awk '{covered=$1; missed=$2; total=covered+missed; if(total>0) print int(covered*100/total); else print 0}') + BRANCH_COVERAGE=$(grep -o 'branch[^>]*covered="[^"]*"[^>]*missed="[^"]*"' "$COVERAGE_XML" | head -1 | sed -E 's/.*covered="([^"]*)".*missed="([^"]*)".*/\1 \2/' | awk '{covered=$1; missed=$2; total=covered+missed; if(total>0) print int(covered*100/total); else print 0}') + LINE_COVERAGE=$(grep -o 'line[^>]*covered="[^"]*"[^>]*missed="[^"]*"' "$COVERAGE_XML" | head -1 | sed -E 's/.*covered="([^"]*)".*missed="([^"]*)".*/\1 \2/' | awk '{covered=$1; missed=$2; total=covered+missed; if(total>0) print int(covered*100/total); else print 0}') + + echo "instruction_coverage=${INSTRUCTION_COVERAGE:-0}" >> $GITHUB_OUTPUT + echo "branch_coverage=${BRANCH_COVERAGE:-0}" >> $GITHUB_OUTPUT + echo "line_coverage=${LINE_COVERAGE:-0}" >> $GITHUB_OUTPUT + + echo "Coverage calculated:" + echo "Instructions: ${INSTRUCTION_COVERAGE:-0}%" + echo "Branches: ${BRANCH_COVERAGE:-0}%" + echo "Lines: ${LINE_COVERAGE:-0}%" + else + echo "โŒ No coverage XML file found" + echo "coverage_file_exists=false" >> $GITHUB_OUTPUT + echo "instruction_coverage=0" >> $GITHUB_OUTPUT + echo "branch_coverage=0" >> $GITHUB_OUTPUT + echo "line_coverage=0" >> $GITHUB_OUTPUT + fi + + - name: Upload coverage reports + uses: actions/upload-artifact@v3 + if: steps.coverage.outputs.coverage_file_exists == 'true' + with: + name: jacoco-coverage-reports + path: | + build/reports/jacoco/ + source/build/reports/jacoco/ + + - name: Comment PR with coverage + uses: actions/github-script@v7 + if: steps.coverage.outputs.coverage_file_exists == 'true' + with: + script: | + const instructionCoverage = ${{ steps.coverage.outputs.instruction_coverage }}; + const branchCoverage = ${{ steps.coverage.outputs.branch_coverage }}; + const lineCoverage = ${{ steps.coverage.outputs.line_coverage }}; + + const coverageComment = ` + ## ๐Ÿ“Š Code Coverage Report + + | Metric | Coverage | + |--------|----------| + | Instructions | ${instructionCoverage}% | + | Branches | ${branchCoverage}% | + | Lines | ${lineCoverage}% | + + ${instructionCoverage >= 30 ? 'โœ…' : 'โŒ'} **Overall Coverage**: ${instructionCoverage}% + + --- + *Coverage report generated by JaCoCo* + `; + + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: coverageComment + }); + + - name: Coverage Check + if: steps.coverage.outputs.coverage_file_exists == 'true' + run: | + INSTRUCTION_COVERAGE=${{ steps.coverage.outputs.instruction_coverage }} + MINIMUM_COVERAGE=30 + + if [ $INSTRUCTION_COVERAGE -lt $MINIMUM_COVERAGE ]; then + echo "โŒ Coverage ($INSTRUCTION_COVERAGE%) is below minimum threshold ($MINIMUM_COVERAGE%)" + echo "Please add more tests to improve coverage." + exit 1 + else + echo "โœ… Coverage ($INSTRUCTION_COVERAGE%) meets minimum threshold ($MINIMUM_COVERAGE%)" + fi \ No newline at end of file diff --git a/JACOCO_USAGE.md b/JACOCO_USAGE.md deleted file mode 100644 index ca589f259..000000000 --- a/JACOCO_USAGE.md +++ /dev/null @@ -1,74 +0,0 @@ -# JaCoCo Code Coverage Integration - -This project has been configured with JaCoCo code coverage analysis. - -## Available Tasks - -### Individual Module Coverage -Run tests and generate coverage report for a specific module: -```bash -./gradlew :source:jacocoTestReport -``` - -### Root/Combined Coverage -Generate a combined coverage report across all modules: -```bash -./gradlew jacocoRootReport -``` - -### Running with Tests -The coverage reports are automatically generated when you run: -```bash -./gradlew check -``` - -## Report Locations - -- **Individual module reports**: `[module]/build/reports/jacoco/jacocoTestReport/html/index.html` -- **Combined report**: `build/reports/jacoco/jacocoRootReport/html/index.html` - -## Configuration - -### Coverage is enabled for: -- Unit tests (`testDebugUnitTest`) -- Android instrumentation tests (when run) - -### Excluded from coverage: -- Generated files (`R.class`, `BuildConfig.*`) -- Android framework files -- Test files -- Kotlin serialization generated classes - -## Thresholds - -To add minimum coverage thresholds, you can configure the `jacocoTestReport` task in each module's `build.gradle.kts`: - -```kotlin -tasks.named("jacocoTestReport") { - // ... existing configuration ... - - doLast { - val report = file("${reportsDir}/jacoco/jacocoTestReport/jacocoTestReport.xml") - // Add your coverage verification logic here - } -} -``` - -## Integration with CI/CD - -The XML reports can be consumed by various CI/CD platforms and tools: -- SonarQube -- Codecov -- Coveralls -- GitHub Actions with coverage badges - -Example for GitHub Actions: -```yaml -- name: Generate Code Coverage Report - run: ./gradlew jacocoTestReport - -- name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - with: - files: ./source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml -``` \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 1e4d38c9f..47c166386 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -80,18 +80,46 @@ tasks.register("jacocoRootReport") { group = "verification" description = "Generate Jacoco coverage reports for all modules." + // Depend on subproject test tasks and jacoco reports dependsOn(subprojects.map { it.tasks.withType() }) + dependsOn(subprojects.map { it.tasks.named("jacocoTestReport") }) reports { xml.required.set(true) html.required.set(true) + csv.required.set(false) } val sourceDirs = subprojects.map { it.file("src/main/java") } + subprojects.map { it.file("src/main/kotlin") } sourceDirectories.setFrom(sourceDirs) - classDirectories.setFrom(subprojects.map { it.file("build/tmp/kotlin-classes/debug") }) - executionData.setFrom(subprojects.map { it.fileTree("build").include("**/*.exec", "**/*.ec") }) + + // Aggregate class directories from all subprojects + val classDirectories = subprojects.flatMap { subproject -> + listOf( + subproject.file("build/tmp/kotlin-classes/debug"), + subproject.file("build/intermediates/javac/debug/classes") + ) + } + this.classDirectories.setFrom(classDirectories) + + // Aggregate execution data from all subprojects + val executionDataFiles = subprojects.flatMap { subproject -> + listOf( + subproject.file("build/jacoco/testDebugUnitTest.exec"), + subproject.file("build/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec"), + subproject.file("build/outputs/code_coverage/debugUnitTest/testDebugUnitTest.exec") + ) + }.filter { it.exists() } + + executionData.setFrom(executionDataFiles) + + doFirst { + println("Root JaCoCo Task Configuration:") + println("Source directories: ${sourceDirectories.files}") + println("Class directories: ${classDirectories.map { it.absolutePath }}") + println("Execution data files: ${executionData.files}") + } } subprojects { diff --git a/source/build.gradle.kts b/source/build.gradle.kts index 1c101c7c0..7a2a35f6c 100644 --- a/source/build.gradle.kts +++ b/source/build.gradle.kts @@ -23,8 +23,7 @@ android { buildTypes { debug { isMinifyEnabled = false - enableUnitTestCoverage = true - enableAndroidTestCoverage = true + isTestCoverageEnabled = true } release { isMinifyEnabled = false @@ -33,6 +32,18 @@ android { } buildFeatures { buildConfig = true } + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + all { + it.systemProperty("robolectric.enabledSdks", "34") + it.systemProperty("robolectric.offline", "true") + it.jvmArgs("-noverify") + } + } + } } tasks.withType().configureEach { @@ -89,45 +100,64 @@ dependencies { ksp(libs.clerk.automap.processor) } -// JaCoCo configuration +// Simple JaCoCo configuration for Robolectric tests tasks.register("jacocoTestReport") { + dependsOn("testDebugUnitTest") group = "verification" - description = "Generate Jacoco coverage reports for the debug build." + description = "Generate Jacoco coverage reports for unit tests" - dependsOn("testDebugUnitTest") - reports { xml.required.set(true) html.required.set(true) + csv.required.set(false) } val fileFilter = listOf( "**/R.class", - "**/R\$*.class", + "**/R\$*.class", "**/BuildConfig.*", "**/Manifest*.*", "**/*Test*.*", "android/**/*.*", "**/*\$WhenMappings.*", "**/*\$serializer.*", - "**/*\$\$serializer.*" + "**/*\$\$serializer.*", + "**/*\$Companion.*" ) - val debugTree = fileTree("${buildDir}/tmp/kotlin-classes/debug") { + val javaClasses = fileTree("${layout.buildDirectory.get().asFile}/intermediates/javac/debug/classes") { + exclude(fileFilter) + } + + val kotlinClasses = fileTree("${layout.buildDirectory.get().asFile}/tmp/kotlin-classes/debug") { exclude(fileFilter) } - val mainSrc = "${project.projectDir}/src/main/java" - val kotlinSrc = "${project.projectDir}/src/main/kotlin" - - sourceDirectories.setFrom(files(mainSrc, kotlinSrc)) - classDirectories.setFrom(files(debugTree)) - executionData.setFrom(fileTree(buildDir) { - include("**/*.exec", "**/*.ec") - }) + classDirectories.setFrom(files(javaClasses, kotlinClasses)) + + sourceDirectories.setFrom(files( + "${project.projectDir}/src/main/java", + "${project.projectDir}/src/main/kotlin" + )) + + // Look for execution data in multiple locations + val executionDataFiles = files( + "${layout.buildDirectory.get().asFile}/jacoco/testDebugUnitTest.exec", + "${layout.buildDirectory.get().asFile}/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec", + "${layout.buildDirectory.get().asFile}/outputs/code_coverage/debugUnitTest/testDebugUnitTest.exec" + ).filter { it.exists() } + + executionData.setFrom(executionDataFiles) + + doFirst { + println("JaCoCo configuration:") + println("Source dirs: ${sourceDirectories.files}") + println("Class dirs: ${classDirectories.files}") + println("Execution data: ${executionData.files}") + } } -// Ensure jacocoTestReport runs after tests +// Ensure test report runs after check tasks.named("check") { dependsOn("jacocoTestReport") } diff --git a/test-coverage-local.sh b/test-coverage-local.sh new file mode 100755 index 000000000..9077237ea --- /dev/null +++ b/test-coverage-local.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +echo "=== Local JaCoCo Coverage Test ===" + +# Check if Android SDK is configured +if [ -z "$ANDROID_HOME" ]; then + echo "โš ๏ธ ANDROID_HOME not set. Attempting to create a minimal local.properties..." + # Try to find Android SDK in common locations + for sdk_path in "$HOME/Android/Sdk" "$HOME/Library/Android/sdk" "/opt/android-sdk"; do + if [ -d "$sdk_path" ]; then + echo "Found Android SDK at: $sdk_path" + echo "sdk.dir=$sdk_path" > local.properties + export ANDROID_HOME="$sdk_path" + break + fi + done + + if [ -z "$ANDROID_HOME" ]; then + echo "โŒ Could not find Android SDK. Please set ANDROID_HOME or install Android SDK." + echo "You can download it from: https://developer.android.com/studio" + exit 1 + fi +fi + +echo "โœ… Android SDK configured at: $ANDROID_HOME" + +# Clean previous build artifacts +echo "๐Ÿงน Cleaning previous build artifacts..." +./gradlew clean + +# Run tests +echo "๐Ÿงช Running unit tests..." +if ./gradlew :source:testDebugUnitTest --info; then + echo "โœ… Tests completed successfully" +else + echo "โš ๏ธ Some tests may have failed, but continuing with coverage generation..." +fi + +# Generate coverage reports +echo "๐Ÿ“Š Generating coverage reports..." +./gradlew :source:jacocoTestReport --info +./gradlew jacocoRootReport --info + +# Check results +echo "" +echo "=== Coverage Report Status ===" + +if [ -f "source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" ]; then + echo "โœ… Individual module coverage report generated" + INDIVIDUAL_REPORT="source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" + + # Extract coverage percentage + INSTRUCTION_COVERED=$(grep -o 'type="INSTRUCTION".*covered="[0-9]*"' "$INDIVIDUAL_REPORT" | sed 's/.*covered="\([0-9]*\)".*/\1/') + INSTRUCTION_MISSED=$(grep -o 'type="INSTRUCTION".*missed="[0-9]*"' "$INDIVIDUAL_REPORT" | sed 's/.*missed="\([0-9]*\)".*/\1/') + + if [ -n "$INSTRUCTION_COVERED" ] && [ -n "$INSTRUCTION_MISSED" ]; then + INSTRUCTION_TOTAL=$((INSTRUCTION_COVERED + INSTRUCTION_MISSED)) + if [ $INSTRUCTION_TOTAL -gt 0 ]; then + INSTRUCTION_PERCENTAGE=$((INSTRUCTION_COVERED * 100 / INSTRUCTION_TOTAL)) + echo "๐Ÿ“ˆ Individual module coverage: $INSTRUCTION_PERCENTAGE%" + fi + fi + + echo "๐Ÿ“ Individual report: source/build/reports/jacoco/jacocoTestReport/html/index.html" +else + echo "โŒ Individual module coverage report NOT generated" +fi + +if [ -f "build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" ]; then + echo "โœ… Root coverage report generated" + ROOT_REPORT="build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" + + # Extract coverage percentage + INSTRUCTION_COVERED=$(grep -o 'type="INSTRUCTION".*covered="[0-9]*"' "$ROOT_REPORT" | sed 's/.*covered="\([0-9]*\)".*/\1/') + INSTRUCTION_MISSED=$(grep -o 'type="INSTRUCTION".*missed="[0-9]*"' "$ROOT_REPORT" | sed 's/.*missed="\([0-9]*\)".*/\1/') + + if [ -n "$INSTRUCTION_COVERED" ] && [ -n "$INSTRUCTION_MISSED" ]; then + INSTRUCTION_TOTAL=$((INSTRUCTION_COVERED + INSTRUCTION_MISSED)) + if [ $INSTRUCTION_TOTAL -gt 0 ]; then + INSTRUCTION_PERCENTAGE=$((INSTRUCTION_COVERED * 100 / INSTRUCTION_TOTAL)) + echo "๐Ÿ“ˆ Root project coverage: $INSTRUCTION_PERCENTAGE%" + fi + fi + + echo "๐Ÿ“ Root report: build/reports/jacoco/jacocoRootReport/html/index.html" +else + echo "โŒ Root coverage report NOT generated" +fi + +# Debug information +echo "" +echo "=== Debug Information ===" +echo "๐Ÿ” Execution data files found:" +find . -name "*.exec" -type f -ls 2>/dev/null || echo "No .exec files found" + +echo "๐Ÿ” Test result files:" +find . -path "*/test-results/*" -name "*.xml" -type f | head -5 || echo "No test result files found" + +echo "๐Ÿ” Build directories:" +ls -la source/build/tmp/kotlin-classes/debug/ 2>/dev/null | head -5 || echo "No debug classes found" + +echo "" +echo "=== Summary ===" +if [ -f "build/reports/jacoco/jacocoRootReport/jacocoRootReport.xml" ] || [ -f "source/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml" ]; then + echo "โœ… JaCoCo coverage reports generated successfully!" + echo "Open the HTML reports in your browser to view detailed coverage information." +else + echo "โŒ No coverage reports were generated. Check the error messages above." + echo "Common issues:" + echo " - Android SDK not properly configured" + echo " - Tests not running due to dependencies" + echo " - JaCoCo plugin configuration issues" +fi \ No newline at end of file