diff --git a/.dockerignore b/.dockerignore index 47119def9..d028c2a4c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -40,6 +40,11 @@ **/gradle-app.setting !**/gradle-wrapper.jar +############################## +## install4j +############################## +**/*.install4j~ + ############################## ## IntelliJ ############################## @@ -118,6 +123,9 @@ exports MapUtilTestDB testconf +ci/test-results +ci/coverage + client/logs client/classes client/dist diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index cc3954b35..c586d1da4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,6 +12,7 @@ on: # miss each other's build cache. env: GRADLE_BUILD_ARGS: ${{ github.ref != 'refs/heads/main' && '-PdisableSigning=true -Pcoverage=true' || '' }} + COVERAGE: ${{ github.ref != 'refs/heads/main' }} jobs: build: @@ -56,6 +57,13 @@ jobs: name: oie-build path: openintegrationengine.tar.gz + - name: Upload unit coverage + if: env.COVERAGE == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage-unit + path: docker-build-output/app/build/coverage + - name: Stage Test Results if: (!cancelled()) run: | @@ -91,13 +99,28 @@ jobs: - name: Expose GitHub Actions runtime for buildx cache uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + - name: Download JaCoCo agent + if: env.COVERAGE == 'true' + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: coverage-unit + path: coverage-unit + # Read-only: the build job populates this cache, and concurrent writers # from the matrix would just fight over the same scope. - name: Run integration tests env: EXTRA_BUILDX_ARGS: --cache-from type=gha,scope=oie-build + JACOCO_AGENT: ${{ env.COVERAGE == 'true' && 'coverage-unit/jacocoagent.jar' || '' }} run: ci/runtests.sh ${{ matrix.configuration }} + - name: Upload smoke-test coverage + if: (!cancelled()) && env.COVERAGE == 'true' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: coverage-smoke-${{ matrix.configuration }} + path: ci/coverage/*.exec + - name: Upload Test Results if: (!cancelled()) uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -123,3 +146,38 @@ jobs: name: Test Results pattern: Test Results (*) delete-merged: true + + coverage: + needs: [build, integration-test] + if: (!cancelled()) && needs.build.result == 'success' && github.ref != 'refs/heads/main' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Download build + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: oie-build + path: artifacts + + - name: Download coverage data + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + pattern: coverage-* + path: artifacts + + # Lay the artifacts out as a local build would leave them. + - name: Merge unit and smoke-test coverage + run: | + mkdir -p server build ci/coverage + tar xzf artifacts/openintegrationengine.tar.gz -C server --transform 's|^openintegrationengine/|setup/|' + cp -r artifacts/coverage-unit build/coverage + cp artifacts/coverage-smoke-*/*.exec ci/coverage/ + ci/coverage-report.sh + + - name: Upload coverage report + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: Coverage Report + path: build/coverage/report diff --git a/.gitignore b/.gitignore index 17542916c..8c90af1e5 100644 --- a/.gitignore +++ b/.gitignore @@ -122,6 +122,7 @@ donkey/donkey-test # /ci /ci/test-results/ +/ci/coverage/ # /client/ /client/logs diff --git a/Dockerfile b/Dockerfile index a59672f5e..ab46dfbe2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,7 +42,8 @@ RUN mkdir -p /home/ubuntu/.gradle RUN --mount=type=cache,target=/home/ubuntu/.gradle/caches,sharing=locked,uid=1000,gid=1000 \ --mount=type=cache,target=/home/ubuntu/.gradle/wrapper,sharing=locked,uid=1000,gid=1000 \ source "$HOME/.sdkman/bin/sdkman-init.sh" \ - && ./gradlew --no-daemon build :smoketest:installHarnessDist ${GRADLE_BUILD_ARGS} + && ./gradlew --no-daemon build :smoketest:installHarnessDist stageCoverage ${GRADLE_BUILD_ARGS} \ + && mkdir -p build/coverage # Stage 1c: Present artifacts for export if not running within docker FROM scratch AS build-output-export @@ -51,6 +52,8 @@ COPY --from=builder /app/client/build/test-results /app/client/build/test-result COPY --from=builder /app/command/build/test-results /app/command/build/test-results/ COPY --from=builder /app/donkey/build/test-results /app/donkey/build/test-results/ COPY --from=builder /app/server/build/test-results /app/server/build/test-results/ +# empty unless built with -Pcoverage=true +COPY --from=builder /app/build/coverage /app/build/coverage/ # Stage 1d: Smoke Test Harness FROM eclipse-temurin:21.0.9_10-jre-noble AS smoketest-harness diff --git a/build.gradle b/build.gradle index d35520992..1f87954ef 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,25 @@ plugins { // vulnerability scanning: ./gradlew dependencyCheckAggregate // (replaces the old standalone dependency-check.xml Ant entry point) alias(libs.plugins.owasp.dependencycheck) + // static analysis: ./gradlew sonar -Dsonar.host.url=... -Dsonar.token=... + alias(libs.plugins.sonarqube) +} + +sonar { + properties { + property 'sonar.projectKey', 'oie' + property 'sonar.projectName', 'Open Integration Engine' + } +} + +allprojects { + sonar { + properties { + // unit and smoke-test coverage merged by ci/coverage-report.sh; + // set on every module, or JaCoCo's per-module default wins + property 'sonar.coverage.jacoco.xmlReportPaths', rootProject.file('build/coverage/report/coverage.xml').path + } + } } dependencyCheck { @@ -263,7 +282,7 @@ subprojects { if (rootProject.coverageEnabled) { apply plugin: 'jacoco' - jacoco { toolVersion = '0.8.8' } + jacoco { toolVersion = libs.versions.jacoco.get() } tasks.withType(Test).configureEach { finalizedBy tasks.named('jacocoTestReport') } tasks.named('jacocoTestReport') { reports.xml.required = true @@ -318,6 +337,36 @@ tasks.register('test') { dependsOn ':donkey:test', ':server:test', ':client:test', ':command:test' } +// Collects what CI needs to merge unit and smoke-test coverage: the unit +// test execution data, the agent for the smoke-test server and the CLI +// that merges and reports. See ci/README.md. +def coverageTools = configurations.create('coverageTools') { transitive = false } +dependencies { + add('coverageTools', variantOf(libs.jacoco.agent) { classifier('runtime') }) + add('coverageTools', variantOf(libs.jacoco.cli) { classifier('nodeps') }) +} +repositories { + mavenCentral() +} + +tasks.register('stageCoverage', Sync) { + group = 'verification' + description = 'Stages unit-test JaCoCo data and the JaCoCo agent and CLI into build/coverage.' + onlyIf { rootProject.coverageEnabled } + mustRunAfter ':donkey:test', ':server:test', ':client:test', ':command:test' + into layout.buildDirectory.dir('coverage') + from(coverageTools) { + rename { it.startsWith('org.jacoco.agent') ? 'jacocoagent.jar' : 'jacococli.jar' } + } + ['donkey', 'server', 'client', 'command'].each { name -> + from(project(":${name}").layout.buildDirectory.dir('jacoco')) { + include '*.exec' + into 'unit' + rename { "${name}-${it}" } + } + } +} + tasks.register('clean') { group = 'build' description = 'Cleans every module.' diff --git a/ci/README.md b/ci/README.md index 1fd3f24e9..e6851f31c 100644 --- a/ci/README.md +++ b/ci/README.md @@ -140,3 +140,25 @@ started yourself logged a generated password on its first boot: JUnit XML results are written to `ci/test-results//` when using `ci/runtests.sh`. + +## Coverage + +CI merges the unit-test and smoke-test coverage into one JaCoCo report (the +`Coverage Report` artifact). To produce the same report locally, build with +coverage, run the smoke tests with the staged agent, then merge: + +```sh +./gradlew build stageCoverage -Pcoverage=true +JACOCO_AGENT=build/coverage/jacocoagent.jar ci/runtests.sh alpine-temurin21-derby +ci/coverage-report.sh +``` + +Each configuration writes `ci/coverage/.exec` when its server stops. +The merged report lands in `build/coverage/report`, where `./gradlew sonar` reads it: + +```sh +./gradlew sonar -Dsonar.host.url=... -Dsonar.token=... +``` + +Override the project key with `-Dsonar.projectKey=...`; SonarCloud also needs +`-Dsonar.organization=...`. diff --git a/ci/coverage-report.sh b/ci/coverage-report.sh new file mode 100755 index 000000000..413687bd5 --- /dev/null +++ b/ci/coverage-report.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Merges unit-test and smoke-test JaCoCo data into build/coverage/report, +# which `./gradlew sonar` picks up. Expects, from the same commit: +# ./gradlew build stageCoverage -Pcoverage=true (server/setup, build/coverage) +# JACOCO_AGENT=build/coverage/jacocoagent.jar ci/runtests.sh +# (ci/coverage/*.exec) +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +cli=build/coverage/jacococli.jar +dist=server/setup +output=build/coverage/report +mkdir -p "$output" + +shopt -s nullglob +unit=(build/coverage/unit/*.exec) +smoke=(ci/coverage/*.exec) +echo "Merging ${#unit[@]} unit-test and ${#smoke[@]} smoke-test data files" +if [[ ${#unit[@]} -eq 0 || ${#smoke[@]} -eq 0 ]]; then + echo "Missing unit-test or smoke-test coverage data" >&2 + exit 1 +fi +java -jar "$cli" merge "${unit[@]}" "${smoke[@]}" --destfile "$output/merged.exec" + +# Only our own jars: third-party jars would add their classes to the report, +# and some of them hold originals of classes we patch in mirth-server.jar. +classfiles=() +for jar in "$dist"/mirth-*-launcher.jar \ + "$dist"/server-lib/mirth-*.jar \ + "$dist"/server-lib/donkey/donkey-*.jar \ + "$dist"/client-lib/mirth-client.jar \ + "$dist"/cli-lib/mirth-cli.jar \ + "$dist"/extensions/*/*.jar; do + classfiles+=(--classfiles "$jar") +done +if [[ ${#classfiles[@]} -eq 0 ]]; then + echo "No jars found in the distribution" >&2 + exit 1 +fi + +sourcefiles=() +for dir in donkey server client command; do + sourcefiles+=(--sourcefiles "$dir/src/main/java") +done + +java -jar "$cli" report "$output/merged.exec" \ + "${classfiles[@]}" "${sourcefiles[@]}" \ + --name "Open Integration Engine (unit + smoke tests)" \ + --xml "$output/coverage.xml" \ + --html "$output/html" diff --git a/ci/coverage.compose.yml b/ci/coverage.compose.yml new file mode 100644 index 000000000..5e81da0d3 --- /dev/null +++ b/ci/coverage.compose.yml @@ -0,0 +1,12 @@ +# Overlay that records JaCoCo coverage of the server under test. Added by +# ci/run-configuration.sh when JACOCO_AGENT points at a jacocoagent.jar. +services: + oie: + environment: + # configure-from-env splits VMOPTIONS on commas, so the agent gets a + # single option and records every class; ci/coverage-report.sh only + # reports our own. The data file is written when the JVM exits. + VMOPTIONS: -javaagent:/jacoco/jacocoagent.jar=destfile=/coverage/${OIE_CONFIGURATION}.exec + volumes: + - ${JACOCO_AGENT}:/jacoco/jacocoagent.jar:ro + - ${WORKSPACE}/ci/coverage:/coverage diff --git a/ci/run-configuration.sh b/ci/run-configuration.sh index 7881f57bb..ee01e39cb 100755 --- a/ci/run-configuration.sh +++ b/ci/run-configuration.sh @@ -23,9 +23,23 @@ export HOST_UID="$(id -u)" export HOST_GID="$(id -g)" mkdir -p "$WORKSPACE/ci/test-results" -compose=(docker compose -f "$1" -f ci/harness.compose.yml -p "oie-ci-${OIE_CONFIGURATION//[^a-z0-9-]/-}-$$") +compose=(docker compose -f "$1" -f ci/harness.compose.yml) +if [[ -n "${JACOCO_AGENT:-}" ]]; then + export JACOCO_AGENT="$(realpath "$JACOCO_AGENT")" + mkdir -p "$WORKSPACE/ci/coverage" + # The server runs as its own user, whose UID differs between the images. + chmod 777 "$WORKSPACE/ci/coverage" + # The agent appends, so start from a clean file. + rm -f "$WORKSPACE/ci/coverage/$OIE_CONFIGURATION.exec" + compose+=(-f ci/coverage.compose.yml) +fi +compose+=(-p "oie-ci-${OIE_CONFIGURATION//[^a-z0-9-]/-}-$$") cleanup() { + if [[ -n "${JACOCO_AGENT:-}" ]]; then + # Give the server time to shut down cleanly so JaCoCo writes its data file. + "${compose[@]}" stop -t 120 oie || true + fi "${compose[@]}" down -v --remove-orphans || true } trap cleanup EXIT diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bb6dc1f29..7d8c9b5df 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,7 @@ hk2 = "2.4.0-b31" httpclient = "4.5.13" itext = "2.1.7" jackson = "2.18.11" +jacoco = "0.8.14" jasper = "8.5.70" jaxb-ri = "2.4.0-b180725.0644" jaxws-ri = "2.3.0.2" @@ -146,6 +147,8 @@ jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", ver jackson-dataformat-cbor = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor", version.ref = "jackson" } jackson-dataformat-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" } jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +jacoco-agent = { module = "org.jacoco:org.jacoco.agent", version.ref = "jacoco" } +jacoco-cli = { module = "org.jacoco:org.jacoco.cli", version.ref = "jacoco" } jakarta-activation = { module = "com.sun.activation:jakarta.activation", version = "1.2.1" } jakarta-mail = { module = "com.sun.mail:jakarta.mail", version = "1.6.8" } jasper-apache-el = { module = "org.mortbay.jasper:apache-el", version.ref = "jasper" } @@ -670,3 +673,4 @@ server-test = [ [plugins] owasp-dependencycheck = { id = "org.owasp.dependencycheck", version = "12.2.2" } +sonarqube = { id = "org.sonarqube", version = "7.5.0.8588" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 9895ca7af..ddf7dedfc 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -2510,41 +2510,52 @@ - - - + + + - - + + + + + - - - + + + + + + - - + + + + - - - + + + + + + - - - + + + - - + + - - - + + + - - + + @@ -2728,6 +2739,14 @@ + + + + + + + + @@ -2851,14 +2870,6 @@ - - - - - - - - @@ -2867,12 +2878,12 @@ - - - + + + - - + + @@ -2883,12 +2894,9 @@ - - - - - - + + + @@ -2899,12 +2907,12 @@ - - - + + + - - + + @@ -2915,6 +2923,14 @@ + + + + + + + + @@ -3057,6 +3073,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +