From 251ad63ea62d170851816b89a0caadafebca0ba4 Mon Sep 17 00:00:00 2001 From: Ludy Date: Mon, 12 Jan 2026 23:17:01 +0100 Subject: [PATCH] feat(build): enhance JaCoCo reporting with coverage summary and enforce thresholds (#5352) # Description of Changes ### What was changed - Refactored Gradle task configuration to use `tasks.named` and `configureEach` for better lazy configuration and compatibility. - Centralized JaCoCo report handling by introducing a single `jacocoReport` task reference. - Added a post-processing step to the JaCoCo XML report to: - Parse coverage metrics (LINE, INSTRUCTION, BRANCH). - Calculate coverage ratios. - Print a formatted coverage summary table directly to the build logs. - Enabled and aligned `jacocoTestCoverageVerification` rules with defined minimum coverage thresholds. - Ensured the `build` task depends on the JaCoCo report to always generate coverage output. ### Why the change was made - To improve visibility of test coverage results directly in CI and local builds without manually opening the HTML report. - To enforce consistent and explicit coverage thresholds for key metrics. - To modernize Gradle task configuration and avoid eager task realization. --- > Task :proprietary:jacocoTestReport ==== JaCoCo Coverage Summary ==== Metric | Coverage | Covered/Total | Status | Target ------------|----------|---------------|--------|---------- LINE | 9.01% | 759/8426 | FAIL | >= 16.00% INSTRUCTION | 8.41% | 2741/32590 | FAIL | >= 14.00% BRANCH | 6.04% | 248/4103 | FAIL | >= 9.00% --- > Task :common:jacocoTestReport ==== JaCoCo Coverage Summary ==== | Metric | Coverage | Covered/Total | Status | Target |------------|----------|---------------|--------|---------- LINE | 39.47% | 2996/7591 | PASS | >= 16.00% INSTRUCTION | 41.05% | 12868/31345 | PASS | >= 14.00% BRANCH | 33.43% | 1166/3488 | PASS | >= 9.00% --- > Task :stirling-pdf:jacocoTestReport ==== JaCoCo Coverage Summary ==== Metric | Coverage | Covered/Total | Status | Target ------------|----------|---------------|--------|---------- LINE | 13.63% | 2554/18741 | FAIL | >= 16.00% INSTRUCTION | 14.59% | 11459/78532 | PASS | >= 14.00% BRANCH | 10.68% | 868/8124 | PASS | >= 9.00% --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- .github/workflows/build.yml | 12 ++++ build.gradle | 130 +++++++++++++++++++++++++++++++++--- 2 files changed, 134 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index beb87d6d6..195601019 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,6 +97,7 @@ jobs: with: name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }} path: | + app/**/build/reports/jacoco/test app/**/build/reports/tests/ app/**/build/test-results/ app/**/build/reports/problems/ @@ -104,6 +105,17 @@ jobs: retention-days: 3 if-no-files-found: warn + - name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }} + id: jacoco + uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2 + with: + paths: | + ${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml + token: ${{ secrets.GITHUB_TOKEN }} + min-coverage-overall: 10 + min-coverage-changed-files: 0 + comment-type: summary + check-generateOpenApiDocs: if: needs.files-changed.outputs.openapi == 'true' needs: [files-changed] diff --git a/build.gradle b/build.gradle index c54e20eb0..88c83b1bb 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,8 @@ plugins { import com.github.jk1.license.render.* import groovy.json.JsonOutput import groovy.json.JsonSlurper +import groovy.xml.XmlSlurper +import org.gradle.api.tasks.testing.Test ext { springBootVersion = "3.5.7" @@ -183,30 +185,142 @@ subprojects { } } - compileJava { - options.compilerArgs << "-parameters" + tasks.named("compileJava", JavaCompile).configure { + options.compilerArgs.add("-parameters") } - test { + def jacocoReport = tasks.named("jacocoTestReport") + + tasks.withType(Test).configureEach { useJUnitPlatform() - finalizedBy jacocoTestReport + finalizedBy(jacocoReport) } - jacocoTestReport { - dependsOn test + jacocoReport.configure { + dependsOn(tasks.named("test")) reports { xml.required.set(true) csv.required.set(false) html.required.set(true) } + doLast { + def xmlReport = reports.xml.outputLocation.get().asFile + if (!xmlReport.exists()) { + logger.lifecycle("Jacoco coverage report not found at ${xmlReport}") + return + } + + def xmlContent = xmlReport.getText("UTF-8") + xmlContent = xmlContent.replaceFirst('(?s)', '') + def report = new XmlSlurper(false, false).parseText(xmlContent) + def counters = report.counter.collectEntries { counter -> + def type = counter.@type.text() + def covered = counter.@covered.text() as BigDecimal + def missed = counter.@missed.text() as BigDecimal + [(type): [covered: covered, missed: missed]] + } + + def thresholds = [ + LINE : 0.16, + INSTRUCTION: 0.14, + BRANCH : 0.09 + ] + + def types = ["LINE", "INSTRUCTION", "BRANCH"] + def headers = ["Metric", "Coverage", "Covered/Total", "Status", "Target"] + + def rows = types.collect { String type -> + def data = counters[type] + if (!data) { + return [type, "—", "—", "No data", ""] + } + + def total = data.covered + data.missed + if (total == 0) { + return [type, "—", "0/${total.toBigInteger()}", "No executions", ""] + } + + def ratio = data.covered / total * 100 + def coverageText = String.format(Locale.ROOT, "%.2f%%", ratio) + def coveredText = String.format(Locale.ROOT, "%d/%d", + data.covered.toBigInteger(), + total.toBigInteger()) + + def threshold = thresholds[type] + def thresholdPercent = threshold != null ? threshold * 100 : null + def targetText = thresholdPercent != null ? + String.format(Locale.ROOT, ">= %.2f%%", thresholdPercent) : "" + def passed = thresholdPercent != null ? ratio >= thresholdPercent : null + def statusText = passed == null ? "" : (passed ? "PASS" : "FAIL") + + return [type, coverageText, coveredText, statusText, targetText] + } + + def columnIndexes = (0.. + Math.max(headers[idx].length(), rows.collect { row -> + row[idx] != null ? row[idx].toString().length() : 0 + }.max() ?: 0) + } + + def formatRow = { List values -> + columnIndexes.collect { idx -> + def value = values[idx] ?: "" + value.padRight(columnWidths[idx]) + }.join(" | ") + } + + def separator = columnIndexes.collect { idx -> + ''.padRight(columnWidths[idx], '-') + }.join("-+-") + + logger.lifecycle("") + logger.lifecycle("==== JaCoCo Coverage Summary ====") + logger.lifecycle(formatRow(headers)) + logger.lifecycle(separator) + rows.each { row -> + logger.lifecycle(formatRow(row)) + } + logger.lifecycle(separator) + + def htmlReport = reports.html.outputLocation.get().asFile + logger.lifecycle("Detailed HTML report available at: ${htmlReport}") + if (rows.any { it[3] == "FAIL" }) { + logger.lifecycle("Some coverage targets were missed. Please review the detailed report above.") + } else if (rows.any { it[3] == "PASS" }) { + logger.lifecycle("Great job! All tracked coverage metrics meet their targets.") + } + logger.lifecycle("=================================\n") + } + } + + tasks.named("build") { + dependsOn jacocoReport } jacocoTestCoverageVerification { - dependsOn jacocoTestReport + dependsOn jacocoReport violationRules { rule { + enabled = true + element = 'BUNDLE' + // Bytecode-Anweisungen abgedeckt limit { - minimum = 0.0 + counter = 'INSTRUCTION' + value = 'COVEREDRATIO' + minimum = 0.14 + } + // wie viele Quellcode-Zeilen abgedeckt + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.16 + } + // Verzweigungen (if/else, switch) abgedeckt; misst Logik-Abdeckung + limit { + counter = 'BRANCH' + value = 'COVEREDRATIO' + minimum = 0.09 } } }