Add CI coverage summaries and aggregate JaCoCo report (#6451)

This commit is contained in:
Anthony Stirling
2026-06-02 14:59:10 +01:00
committed by GitHub
parent 2c0ebc28a7
commit de9d6ad3f5
15 changed files with 1583 additions and 23 deletions
+125 -1
View File
@@ -438,7 +438,7 @@ subprojects {
}
tasks.named("bootRun") {
jvmArgs = [
def runtimeArgs = [
"-XX:+UseG1GC",
"-XX:MaxGCPauseMillis=200",
"-XX:G1HeapRegionSize=4m",
@@ -447,6 +447,53 @@ subprojects {
"-XX:+UseCompactObjectHeaders",
"--enable-native-access=ALL-UNNAMED"
]
// Optional JaCoCo agent for e2e/cucumber backend coverage.
//
// Enabled with `-PjacocoAgent=true`. The default destfile lives
// outside the source tree so it doesn't poison a normal bootRun
// for a developer who forgot to pass the property. Override
// both with `-PjacocoExec=/path/to.exec` if you need a custom
// location (e.g. when sharing it with the Playwright runner).
if (rootProject.hasProperty('jacocoAgent') &&
rootProject.property('jacocoAgent').toString() == 'true') {
def agentJar = rootProject.layout.buildDirectory
.file('jacoco/jacocoagent.jar').get().asFile.absolutePath
def execFile = (rootProject.findProperty('jacocoExec') ?:
rootProject.layout.projectDirectory
.file('.test-state/playwright/jacoco.exec').asFile.absolutePath
).toString()
// The JaCoCo agent argument is a comma-separated key=value list,
// so a path that itself contains ',' or '=' would smuggle extra
// agent options (e.g. -PjacocoExec='out.exec,sessionid=evil').
// Today the value only comes from our own CI workflows, but
// validating defensively is cheap and silences Aikido.
//
// Control characters are checked via String.contains rather
// than inside a slashy regex character class - a literal NULL
// accidentally landing in the regex killed Groovy parsing on
// the previous attempt.
def hasBadChar = execFile.find(/[,= ]/) != null ||
execFile.contains('\r') ||
execFile.contains('\n') ||
execFile.contains('\t')
if (hasBadChar) {
throw new GradleException(
"jacocoExec='" + execFile + "' contains characters " +
"(',', '=', whitespace, or control chars) that " +
"would break -javaagent option parsing. Choose " +
"a different path."
)
}
runtimeArgs.add("-javaagent:${agentJar}=destfile=${execFile},output=file,append=false,dumponexit=true")
dependsOn(rootProject.tasks.named('copyJacocoAgent'))
doFirst {
new File(execFile).parentFile?.mkdirs()
logger.lifecycle("JaCoCo agent attached: ${execFile}")
}
}
jvmArgs = runtimeArgs
}
}
}
@@ -627,3 +674,80 @@ tasks.withType(Test).configureEach {
// Half of available CPUs is a safe default; bump if your tests are I/O-bound.
maxParallelForks = Math.max(1, (Runtime.runtime.availableProcessors().intdiv(2)) as int)
}
// ----------------------------------------------------------------------------
// JaCoCo helpers used by CI for cucumber + Playwright (live backend) coverage.
//
// These let CI attach the JaCoCo agent to a running Spring Boot process
// (gradle bootRun or the docker image, via JAVA_TOOL_OPTIONS) and then turn
// the resulting .exec dump back into HTML + XML reports without anyone
// hand-installing the JaCoCo CLI.
// ----------------------------------------------------------------------------
configurations {
jacocoRuntimeAgent
jacocoCli
}
dependencies {
// The :runtime classifier on org.jacoco.agent IS the agent jar - no
// unzipping required. Kept on the version the plugin already picks so
// agent + reporter line up exactly.
jacocoRuntimeAgent "org.jacoco:org.jacoco.agent:${jacoco.toolVersion}:runtime"
jacocoCli "org.jacoco:org.jacoco.cli:${jacoco.toolVersion}"
}
tasks.register('copyJacocoAgent', Copy) {
group = 'verification'
description = 'Copies the JaCoCo runtime agent jar to build/jacoco/jacocoagent.jar.'
from configurations.jacocoRuntimeAgent
into layout.buildDirectory.dir('jacoco')
rename { 'jacocoagent.jar' }
}
// Aggregates an externally-produced .exec (e.g. from e2e:live or the cucumber
// docker container) against this project's compiled classes + sources.
//
// Inputs are configured via -P properties so the same task works for every
// caller:
//
// ./gradlew jacocoReportFromExec -PexecFile=.test-state/playwright/jacoco.exec \
// -PreportDir=build/reports/jacoco/e2e-live
tasks.register('jacocoReportFromExec', JacocoReport) {
group = 'verification'
description = 'Generates a JaCoCo HTML+XML report from an externally captured .exec.'
def execProp = project.findProperty('execFile') ?: project.findProperty('execFiles')
def reportProp = project.findProperty('reportDir') ?: "build/reports/jacoco/external"
executionData fileTree(rootProject.rootDir) {
if (execProp) {
include execProp.toString().split(',').collect { it.trim() }
} else {
// Sensible defaults so the task is usable without props.
include '.test-state/**/*.exec'
include 'coverage-tools/exec/*.exec'
include 'testing/cucumber-coverage/*.exec'
}
}
// Pull compiled classes + sources from every subproject so the report is
// an aggregate. JaCoCo silently skips classes that have no matching .exec
// probes, so this is safe even when only a subset of code was exercised.
classDirectories.setFrom(files(subprojects.collect { sub ->
sub.fileTree(dir: "${sub.buildDir}/classes/java/main", excludes: [
'**/generated/**',
])
}))
sourceDirectories.setFrom(files(subprojects.collect { sub ->
"${sub.projectDir}/src/main/java"
}))
reports {
xml.required.set(true)
html.required.set(true)
csv.required.set(false)
xml.outputLocation.set(layout.projectDirectory.file("${reportProp}/jacocoTestReport.xml"))
html.outputLocation.set(layout.projectDirectory.dir("${reportProp}/html"))
}
}