diff --git a/.dockerignore b/.dockerignore index 49ae130868..47119def9a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,63 +1,238 @@ -# Include any files or directories that you don't want to be copied to your -# container here (e.g., local build artifacts, temporary files, etc.). -# -# For more help, visit the .dockerignore file reference guide at -# https://docs.docker.com/go/build-context-dockerignore/ +# GENERATED FROM .gitignore by tools/gen-dockerignore.sh -- DO NOT EDIT BY HAND. +# .gitignore is authoritative; edit it and re-run the generator. +# (git and docker use opposite default path anchoring -- see the script.) -**/.DS_Store -**/.classpath -**/.dockerignore -**/.env -**/.factorypath -**/.git -**/.gitignore -**/.idea -**/.project -**/.sts4-cache -**/.settings -**/.toolstarget -**/.vs -**/.vscode -**/.next -**/.cache -**/*.dbmdl -**/*.jfm -**/charts -**/docker-compose* -**/compose.y*ml -**/Dockerfile* -**/secrets.dev.yaml -**/values.dev.yaml -**/vendor -LICENSE -README.md +############################## +## Java +############################## +**/.mtj.tmp **/*.class +# Re-enable after move to MVN or Gradle +#*.jar +**/*.war +**/*.ear +**/*.nar +**/hs_err_pid* +**/replay_pid* + +############################## +## Maven +############################## +**/target +**/pom.xml.tag +**/pom.xml.releaseBackup +**/pom.xml.versionsBackup +**/pom.xml.next +**/pom.xml.bak +**/release.properties +**/dependency-reduced-pom.xml +**/buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +**/bin +**/build +**/.gradle +**/.gradletasknamecache +**/gradle-app.setting +!**/gradle-wrapper.jar + +############################## +## IntelliJ +############################## +**/out +**/.idea +**/.idea_modules **/*.iml **/*.ipr **/*.iws -**/*.log -**/.apt_generated -**/.gradle -**/.gradletasknamecache -**/.nb-gradle -**/.springBeans + +############################## +## Eclipse +############################## +**/.settings +**/bin +**/tmp +**/.metadata +**/.classpath +**/.project +**/*.tmp +**/*.bak +**/*.swp +**/*~.nib +**/local.properties +**/.loadpath +**/.factorypath + +############################## +## NetBeans +############################## +nbproject/private **/build -**/dist -**/gradle-app.setting **/nbbuild +**/dist **/nbdist -**/nbproject/private -**/target -*.ctxt -.mtj.tmp -.mvn/timing.properties -buildNumber.properties -dependency-reduced-pom.xml -hs_err_pid* -pom.xml.next -pom.xml.releaseBackup -pom.xml.tag -pom.xml.versionsBackup -release.properties -replay_pid* +**/nbactions.xml +**/nb-configuration.xml + +############################## +## Visual Studio Code +############################## +**/.vs +**/.vscode +**/.code-workspace + +############################## +## OS X +############################## +**/.DS_Store + +############################## +## Windows +############################## +**/Thumbs.db +**/ehthumbs.db +**/Desktop.ini + +############################## +## Miscellaneous +############################## +**/*.log + +############################## +## Project Specific +############################## +**/.java-version +**/*.iml +donkey/donkey-src +donkey/donkey-test +**/.sonar +# Unit Tests: +**/junit-reports +**/junit-html +**/code-coverage-reports +exports +MapUtilTestDB +testconf + +client/logs +client/classes +client/dist +client/about.txt +client/bin +client/build +client/nbproject +client/build.xml +client/manifest.mf +client/.settings +client/nbbuild +client/nbdist +client/test_classes +client/junit-reports + +client/lib/mirth-client-core.jar +client/lib/mirth-crypto.jar +client/lib/*-shared.jar +client/lib/nblibraries-private.properties +client/lib/mirth-vocab.jar +client/lib/donkey-model.jar + +client/src/main/java/com/mirth/connect/client/ui/util/DocParser.java + +client/src/main/java/com/mirth/connect/plugins/sampledashboardcolumn + +command/.settings +command/bin +command/build +command/classes +command/dist +command/junit-reports + +command/lib/mirth-client-core.jar +command/lib/mirth-crypto.jar +command/lib/donkey-model.jar + +donkey/classes +donkey/setup +donkey/bin +donkey/build +donkey/dist +donkey/donkeydb +donkey/nb-build.xml +donkey/nbproject +donkey/nbbuild +donkey/nbdist +donkey/test_classes +donkey/junit-reports + +generator/.settings +generator/bin +generator/classes +generator/dist +generator/model +generator/vocab + +server/mirthdb +server/logs +server/classes +server/setup +server/bin +server/derby.log +server/dist +server/.settings +server/build +server/appdata +server/exports +server/temp +server/nbbuild +server/nbproject +server/nb-build.xml +server/version.properties +server/installer +server/.classpath.bak +server/junit-reports +server/test_classes +server/testdist +server/testconf + +server/conf/mule-config.xml + +server/docs/javadocs + +server/lib/licenses +server/lib/opt +server/lib/commons-logging-1.0.4.jar +server/lib/itext-2.0.1.jar +server/lib/patch +server/lib/jdic.dll +server/lib/jdic.jar +server/lib/swing-layout-1.0.1.jar +server/lib/tray.dll +server/lib/looks-2.1.1.jar +server/lib/donkey + +server/src/main/resources/com/mirth/connect/plugins/Graphical Mapper Transformer Step.xml +server/src/main/java/com/mirth/connect/plugins/consoleconnectorstatus +server/src/main/resources/com/mirth/connect/plugins/Console Connector Status Monitor Plugin.xml +server/src/main/java/com/mirth/connect/plugins/extendedhl7vocab +server/src/main/resources/com/mirth/connect/plugins/Extended HL7 Vocabulary.xml +server/src/main/resources/com/mirth/connect/plugins/Sample Dashboard Column Plugin.xml +server/src/main/resources/com/mirth/connect/plugins/ExtensionParser.xml +server/src/main/java/com/mirth/connect/plugins/ExtensionRepositoryParser.java + +server/src/main/java/com/mirth/connect/server/builders/VocalEZParser.java + +server/src/main/java/com/mirth/connect/util/GenerateExtensionUpdate.java + +server/setup.tar.gz +**/dependency-check-report.html +**/dependency-check-report.json + +# ---- Docker-specific (not build outputs, so not in .gitignore) ---- +.git .github/ +**/Dockerfile* +**/.dockerignore diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 98b202a02a..9e5dfc1a85 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -25,28 +25,34 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up JDK - uses: actions/setup-java@v4 - with: - java-version: '17' - java-package: 'jdk+fx' - distribution: 'zulu' + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Set up Gradle - uses: gradle/actions/setup-gradle@ed408507eac070d1f99cc633dbcf757c94c7933a # v4.4.3 - with: - validate-wrappers: true + - name: Expose GitHub Actions runtime for buildx cache + uses: crazy-max/ghaction-github-runtime@v3 - - name: Build OIE (signed) - if: github.ref == 'refs/heads/main' - run: ./gradlew build + - name: Set build metadata + run: | + if [ "${GITHUB_REF}" = "refs/heads/main" ]; then + echo "GRADLE_BUILD_ARGS=" >> "$GITHUB_ENV" + else + echo "GRADLE_BUILD_ARGS=-PdisableSigning=true -Pcoverage=true" >> "$GITHUB_ENV" + fi - - name: Build OIE (unsigned) - if: github.ref != 'refs/heads/main' - run: ./gradlew build -PdisableSigning=true -Pcoverage=true + - name: Build and run tests + run: | + rm -rf docker-build-output + docker buildx build \ + --progress=plain \ + --build-arg "GRADLE_BUILD_ARGS=${GRADLE_BUILD_ARGS}" \ + --cache-from type=gha,scope=oie-build \ + --cache-to type=gha,scope=oie-build,mode=max \ + --target build-output-export \ + --output type=local,dest=docker-build-output \ + . - name: Package distribution - run: tar czf openintegrationengine.tar.gz -C server/ setup --transform 's|^setup|openintegrationengine/|' + run: tar czf openintegrationengine.tar.gz -C docker-build-output/app/server setup --transform 's|^setup|openintegrationengine/|' - name: Create artifact uses: actions/upload-artifact@v4 @@ -54,12 +60,22 @@ jobs: name: oie-build path: openintegrationengine.tar.gz + - name: Run integration tests + env: + EXTRA_BUILDX_ARGS: >- + --cache-from type=gha,scope=oie-build + --cache-to type=gha,scope=oie-build,mode=max + run: ci/runtests.sh + - name: Stage Test Results if: (!cancelled()) run: | mkdir -p aggregate-test-results - # Copy the directory structures - cp -r --parents */build/test-results aggregate-test-results/ + cp -r --parents docker-build-output/app/client/build/test-results aggregate-test-results/ + cp -r --parents docker-build-output/app/command/build/test-results aggregate-test-results/ + cp -r --parents docker-build-output/app/donkey/build/test-results aggregate-test-results/ + cp -r --parents docker-build-output/app/server/build/test-results aggregate-test-results/ + cp -r ci/test-results aggregate-test-results/ - name: Upload Test Results if: (!cancelled()) diff --git a/.gitignore b/.gitignore index 787defcb62..9e8e4e77f3 100644 --- a/.gitignore +++ b/.gitignore @@ -115,6 +115,9 @@ donkey/donkey-test /MapUtilTestDB/ /testconf/ +# /ci +/ci/test-results/ + # /client/ /client/logs /client/classes diff --git a/Dockerfile b/Dockerfile index 4ed4782cd3..f1e766eb3f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,24 @@ COPY . . RUN --mount=type=cache,target=/root/.gradle/caches,sharing=locked \ --mount=type=cache,target=/root/.gradle/wrapper,sharing=locked \ source "$HOME/.sdkman/bin/sdkman-init.sh" \ - && ./gradlew --no-daemon build ${GRADLE_BUILD_ARGS} + && ./gradlew --no-daemon build :smoketest:installHarnessDist ${GRADLE_BUILD_ARGS} + +# Stage 1c: Present artifacts for export if not running within docker +FROM scratch AS build-output-export +COPY --from=builder /app/server/setup /app/server/setup/ +COPY --from=builder /app/client/build/test-results /app/client/build/test-results/ +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/ + +# Stage 1d: Smoke Test Harness +FROM eclipse-temurin:21.0.9_10-jre-noble AS smoketest-harness + +COPY --from=builder /app/server/setup/server-lib /opt/engine/server-lib +COPY --from=builder /app/server/setup/extensions /opt/engine/extensions +COPY --from=builder /app/smoketest/build/install/smoketest-harness /harness + +ENTRYPOINT ["/bin/bash", "/harness/run-harness.sh"] ########################################## # diff --git a/build.gradle b/build.gradle index 622069b107..b944734114 100644 --- a/build.gradle +++ b/build.gradle @@ -192,9 +192,11 @@ subprojects { options.annotationProcessorPath = configurations.testCompileClasspath } - // The default java-plugin jar (build/libs/.jar) is not an OIE - // artifact; every real jar is declared explicitly per module. - tasks.named('jar') { enabled = false } + // Legacy modules assemble their published archives explicitly. + // Newer modules use Gradle's normal JAR convention. + if (project.name in ['donkey', 'server', 'client', 'command', 'generator']) { + tasks.named('jar') { enabled = false } + } tasks.withType(AbstractArchiveTask).configureEach { preserveFileTimestamps = false diff --git a/ci/README.md b/ci/README.md new file mode 100644 index 0000000000..406e451e25 --- /dev/null +++ b/ci/README.md @@ -0,0 +1,111 @@ +# CI Smoke Tests + +The CI smoke tests exercise the Docker image against each supported runtime and +database configuration. They deploy exported channels, submit messages through the +OIE client API, and assert the resulting source and destination messages. + +Most tests are filesystem fixtures. Use a Java test when the scenario needs logic +that cannot be expressed as input data and expected results. + +## What Is Tested + +Every configuration in `ci/configurations/` runs the applicable test cases in +`ci/tests/`. + +A fixture test case can contain one or more channels. Each channel is deployed from +its exported `channel.xml`; its message fixtures are then run in lexicographic order. +The test case, channel, and message directory names appear in the test report, so +use names that describe the behavior being checked. + +## Add a Fixture Test + +Create a directory under `ci/tests/` using this structure: + +```text +ci/tests/ + 200-my-feature/ + configurations # optional + channels/ + 01-my-channel/ + channel.xml + messages/ + 01-happy-path/ + source + dest01 +``` + +`channel.xml` is an exported OIE channel. Every message fixture requires `source`, +the payload sent to the channel. The other files are optional assertions: + +| File | Assertion | +| --- | --- | +| `source_sourcemap.yml` | Source map supplied with `source` | +| `source_metadata.yml` | Selected source message metadata | +| `source_status` | Source status | +| `source_response` | Source response payload | +| `source_transformed` | Transformed source payload | +| `destNN` | Sent payload for destination `NN` | +| `destNN_transformed` | Transformed payload for destination `NN` | +| `destNN_response` | Response payload from destination `NN` | +| `destNN_metadata.yml` | Selected metadata for destination `NN` | +| `destNN_status` | Status for destination `NN` | + +`NN` is the destination connector's metadata ID: `dest01` is the first +destination, `dest02` the second, and so on. Metadata files are YAML mappings; list +only the keys that matter to the test. Content assertions are byte-for-byte, except +that `((ANY))` matches variable content such as generated IDs or timestamps. + +A fixture case runs in every configuration by default. To limit it, add a +`configurations` file at the case root with one configuration name per line: + +```text +alpine-temurin21-postgres +ubuntu-temurin21-postgres +``` + +## Add a Configuration + +Add a Compose file at `ci/configurations/.compose.yml`. Configuration names +normally follow `--`, such as +`alpine-temurin21-postgres`. + +The Compose file must: + +- define the OIE service as `oie`; +- use `${OIE_IMAGE}` for that service's image; +- provide Docker healthchecks for OIE and any database dependency. + +The new configuration will run all fixture cases unless their `configurations` file +excludes it. Add an entry there when a fixture depends on a specific database, +operating system, or JVM behavior. + +## Add a Java Test + +Put hand-written JUnit 5 tests in +`smoketest/src/test/java/org/openintegrationengine/smoketest/`. Use the existing +`Harness` and `OieServer` helpers to work with the live server. Java tests run with +the fixture tests and are appropriate for multi-step workflows, computed +expectations, or assertions that do not fit the fixture files. + +## Run Locally + +Run every configuration: + +```sh +ci/runtests.sh +``` + +Run one configuration: + +```sh +ci/runtests.sh alpine-temurin21-derby +``` + +To iterate against an already-running server: + +```sh +./gradlew :smoketest:test -Doie.baseUrl=https://localhost:8443 +``` + +JUnit XML results are written to `ci/test-results//` when using +`ci/runtests.sh`. diff --git a/ci/configurations/alpine-temurin21-derby.compose.yml b/ci/configurations/alpine-temurin21-derby.compose.yml new file mode 100644 index 0000000000..5aa8ebe77e --- /dev/null +++ b/ci/configurations/alpine-temurin21-derby.compose.yml @@ -0,0 +1,13 @@ +services: + oie: + image: ${OIE_IMAGE} + ports: + - "8443:8443" + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8443' + interval: 3s + timeout: 5s + retries: 30 + start_period: 40s diff --git a/ci/configurations/alpine-temurin21-mysql.compose.yml b/ci/configurations/alpine-temurin21-mysql.compose.yml new file mode 100644 index 0000000000..feec339209 --- /dev/null +++ b/ci/configurations/alpine-temurin21-mysql.compose.yml @@ -0,0 +1,40 @@ +services: + db: + image: mariadb:11.8 + environment: + MARIADB_ROOT_PASSWORD: root_password + MARIADB_DATABASE: mirthdb + MARIADB_USER: mirthdb + MARIADB_PASSWORD: mirthdb + healthcheck: + test: + - CMD-SHELL + - mariadb-admin ping -h 127.0.0.1 -umirthdb -pmirthdb + interval: 3s + timeout: 5s + retries: 20 + start_period: 20s + + oie: + image: ${OIE_IMAGE} + depends_on: + db: + condition: service_healthy + environment: + DATABASE: mysql + DATABASE_URL: jdbc:mysql://db:3306/mirthdb + DATABASE_USERNAME: mirthdb + DATABASE_PASSWORD: mirthdb + DATABASE_MAX_CONNECTIONS: 20 + DATABASE_MAX_RETRY: 10 + DATABASE_RETRY_WAIT: 5000 + ports: + - "8443:8443" + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8443' + interval: 3s + timeout: 5s + retries: 30 + start_period: 40s diff --git a/ci/configurations/alpine-temurin21-postgres.compose.yml b/ci/configurations/alpine-temurin21-postgres.compose.yml new file mode 100644 index 0000000000..4f728f7e46 --- /dev/null +++ b/ci/configurations/alpine-temurin21-postgres.compose.yml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:18.3-alpine + environment: + POSTGRES_USER: mirthdb + POSTGRES_PASSWORD: mirthdb + POSTGRES_DB: mirthdb + healthcheck: + test: + - CMD-SHELL + - pg_isready -U mirthdb -d mirthdb + interval: 3s + timeout: 5s + retries: 15 + start_period: 5s + + oie: + image: ${OIE_IMAGE} + depends_on: + db: + condition: service_healthy + environment: + DATABASE: postgres + DATABASE_URL: jdbc:postgresql://db:5432/mirthdb + DATABASE_USERNAME: mirthdb + DATABASE_PASSWORD: mirthdb + DATABASE_MAX_CONNECTIONS: 20 + DATABASE_MAX_RETRY: 10 + DATABASE_RETRY_WAIT: 5000 + ports: + - "8443:8443" + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8443' + interval: 3s + timeout: 5s + retries: 30 + start_period: 40s diff --git a/ci/configurations/alpine-temurin21-sqlserver.compose.yml b/ci/configurations/alpine-temurin21-sqlserver.compose.yml new file mode 100644 index 0000000000..fbc8bd7ae4 --- /dev/null +++ b/ci/configurations/alpine-temurin21-sqlserver.compose.yml @@ -0,0 +1,54 @@ +services: + db: + platform: linux/amd64 + image: mcr.microsoft.com/mssql/server:2025-latest + environment: + ACCEPT_EULA: Y + MSSQL_PID: Developer + MSSQL_SA_PASSWORD: OieSqlServerPassw0rd! + healthcheck: + test: + - CMD-SHELL + - /opt/mssql-tools18/bin/sqlcmd -C -S 127.0.0.1 -U sa -P "$$MSSQL_SA_PASSWORD" -Q "SELECT 1" || /opt/mssql-tools/bin/sqlcmd -S 127.0.0.1 -U sa -P "$$MSSQL_SA_PASSWORD" -Q "SELECT 1" + interval: 3s + timeout: 5s + retries: 30 + start_period: 30s + + db-init: + platform: linux/amd64 + image: mcr.microsoft.com/mssql/server:2025-latest + depends_on: + db: + condition: service_healthy + environment: + MSSQL_SA_PASSWORD: OieSqlServerPassw0rd! + restart: "no" + entrypoint: + - /bin/bash + - -lc + - /opt/mssql-tools18/bin/sqlcmd -C -S db -U sa -P "$$MSSQL_SA_PASSWORD" -Q "IF DB_ID('mirthdb') IS NULL CREATE DATABASE mirthdb;" || /opt/mssql-tools/bin/sqlcmd -S db -U sa -P "$$MSSQL_SA_PASSWORD" -Q "IF DB_ID('mirthdb') IS NULL CREATE DATABASE mirthdb;" + + oie: + image: ${OIE_IMAGE} + depends_on: + db-init: + condition: service_completed_successfully + environment: + DATABASE: sqlserver + DATABASE_URL: jdbc:jtds:sqlserver://db:1433/mirthdb + DATABASE_USERNAME: sa + DATABASE_PASSWORD: OieSqlServerPassw0rd! + DATABASE_MAX_CONNECTIONS: 20 + DATABASE_MAX_RETRY: 20 + DATABASE_RETRY_WAIT: 5000 + ports: + - "8443:8443" + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8443' + interval: 3s + timeout: 5s + retries: 30 + start_period: 40s diff --git a/ci/configurations/ubuntu-temurin21-derby.compose.yml b/ci/configurations/ubuntu-temurin21-derby.compose.yml new file mode 100644 index 0000000000..5aa8ebe77e --- /dev/null +++ b/ci/configurations/ubuntu-temurin21-derby.compose.yml @@ -0,0 +1,13 @@ +services: + oie: + image: ${OIE_IMAGE} + ports: + - "8443:8443" + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8443' + interval: 3s + timeout: 5s + retries: 30 + start_period: 40s diff --git a/ci/configurations/ubuntu-temurin21-postgres.compose.yml b/ci/configurations/ubuntu-temurin21-postgres.compose.yml new file mode 100644 index 0000000000..4f728f7e46 --- /dev/null +++ b/ci/configurations/ubuntu-temurin21-postgres.compose.yml @@ -0,0 +1,39 @@ +services: + db: + image: postgres:18.3-alpine + environment: + POSTGRES_USER: mirthdb + POSTGRES_PASSWORD: mirthdb + POSTGRES_DB: mirthdb + healthcheck: + test: + - CMD-SHELL + - pg_isready -U mirthdb -d mirthdb + interval: 3s + timeout: 5s + retries: 15 + start_period: 5s + + oie: + image: ${OIE_IMAGE} + depends_on: + db: + condition: service_healthy + environment: + DATABASE: postgres + DATABASE_URL: jdbc:postgresql://db:5432/mirthdb + DATABASE_USERNAME: mirthdb + DATABASE_PASSWORD: mirthdb + DATABASE_MAX_CONNECTIONS: 20 + DATABASE_MAX_RETRY: 10 + DATABASE_RETRY_WAIT: 5000 + ports: + - "8443:8443" + healthcheck: + test: + - CMD-SHELL + - bash -lc 'exec 3<>/dev/tcp/127.0.0.1/8443' + interval: 3s + timeout: 5s + retries: 30 + start_period: 40s diff --git a/ci/harness.compose.yml b/ci/harness.compose.yml new file mode 100644 index 0000000000..cd9fe0c1af --- /dev/null +++ b/ci/harness.compose.yml @@ -0,0 +1,16 @@ +# Overlay that adds the smoke-test harness to any configuration under +# ci/configurations. +services: + harness: + image: oie-ci-harness:local + depends_on: + oie: + condition: service_healthy + # Tests are run with the UID/GID of the host so we don't have permission issues + user: "${HOST_UID}:${HOST_GID}" + environment: + HOME: /tmp + OIE_BASE_URL: https://oie:8443 + OIE_CONFIGURATION: ${OIE_CONFIGURATION} + volumes: + - ${WORKSPACE}/ci/test-results:/results diff --git a/ci/run-configuration.sh b/ci/run-configuration.sh new file mode 100755 index 0000000000..44878c69e1 --- /dev/null +++ b/ci/run-configuration.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Runs one smoke-test configuration using existing server and harness images. +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +if [[ ! -f "$1" ]]; then + echo "No such configuration: $1" >&2 + exit 2 +fi +export OIE_IMAGE="$2" +export OIE_CONFIGURATION="$(basename "$1" .compose.yml)" +export WORKSPACE="$PWD" +export HOST_UID="$(id -u)" +export HOST_GID="$(id -g)" + +compose=(docker compose -f "$1" -f ci/harness.compose.yml -p "oie-ci-${OIE_CONFIGURATION//[^a-z0-9-]/-}-$$") + +cleanup() { + "${compose[@]}" down -v --remove-orphans || true +} +trap cleanup EXIT + +echo "==> $OIE_CONFIGURATION: starting stack (image $OIE_IMAGE)" +if ! "${compose[@]}" up -d --wait oie; then + "${compose[@]}" logs --no-color oie >&2 || true + exit 1 +fi + +echo "==> $OIE_CONFIGURATION: running the harness" +"${compose[@]}" run --rm harness \ No newline at end of file diff --git a/ci/run-harness.sh b/ci/run-harness.sh new file mode 100755 index 0000000000..dadecf9954 --- /dev/null +++ b/ci/run-harness.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Entrypoint of the harness container (see Dockerfile smoketest-harness target) +set -euo pipefail + +ENGINE_HOME="${ENGINE_HOME:-/opt/engine}" +HARNESS_DIR="${HARNESS_DIR:-/harness}" +RESULTS_DIR="${RESULTS_DIR:-/results}" + +# Build our classpath from the harness and engine jars, sorted so that the engine jars are first. +classpath=$(find "$ENGINE_HOME/server-lib" "$ENGINE_HOME/extensions" "$HARNESS_DIR" \ + -name '*.jar' -type f | sort | paste -sd: -) + +results="$RESULTS_DIR/$OIE_CONFIGURATION" +mkdir -p "$results" + +# Lock engine to junit-jupiter to prevent false-pass results. +status=0 +java \ + -Doie.baseUrl="$OIE_BASE_URL" \ + -Doie.configuration="$OIE_CONFIGURATION" \ + ${OIE_HARNESS_OPTS:-} \ + -cp "$classpath" \ + org.junit.platform.console.ConsoleLauncher execute \ + --select-package=org.openintegrationengine.smoketest \ + --include-engine=junit-jupiter \ + --details=tree \ + --disable-ansi-colors \ + --fail-if-no-tests \ + --reports-dir="$results" || status=$? + +if ! compgen -G "$results/*.xml" > /dev/null; then + echo "The harness produced no report in $results" >&2 + exit 1 +fi + +exit "$status" diff --git a/ci/runtests.sh b/ci/runtests.sh new file mode 100755 index 0000000000..674ac50dca --- /dev/null +++ b/ci/runtests.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Runs the Docker smoke-test pipeline. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +ALPINE_IMAGE="oie-ci-server:local-alpine-temurin21" +UBUNTU_IMAGE="oie-ci-server:local-ubuntu-temurin21" + +if [[ $# -gt 1 ]]; then + echo "Usage: $0 [configuration]" >&2 + exit 2 +fi + +configuration="${1:-all}" + +echo "==> Building server images" +if [[ "$configuration" == "all" || "$configuration" != ubuntu-* ]]; then + docker buildx build --load ${EXTRA_BUILDX_ARGS:-} --target jre-run -t "$ALPINE_IMAGE" . +fi +if [[ "$configuration" == "all" || "$configuration" == ubuntu-* ]]; then + docker buildx build --load ${EXTRA_BUILDX_ARGS:-} --target jdk-run -t "$UBUNTU_IMAGE" . +fi + +echo "==> Building harness image" +docker buildx build --load ${EXTRA_BUILDX_ARGS:-} --target smoketest-harness -t oie-ci-harness:local . + +run_configuration() { + case "$1" in + ci/configurations/ubuntu-*) ci/run-configuration.sh "$1" "$UBUNTU_IMAGE" ;; + *) ci/run-configuration.sh "$1" "$ALPINE_IMAGE" ;; + esac +} + +if [[ "$configuration" == "all" ]]; then + for compose_file in ci/configurations/*.compose.yml; do + run_configuration "$compose_file" + done +else + run_configuration "ci/configurations/$configuration.compose.yml" +fi diff --git a/ci/tests/101-raw-no-op/channels/01-raw-no-op/channel.xml b/ci/tests/101-raw-no-op/channels/01-raw-no-op/channel.xml new file mode 100644 index 0000000000..923b5d43e6 --- /dev/null +++ b/ci/tests/101-raw-no-op/channels/01-raw-no-op/channel.xml @@ -0,0 +1,184 @@ + + 62af393b-ff61-47ec-b5fa-ccd2cd08ce55 + 2 + Noop + + 1 + + 0 + sourceConnector + + + + None + true + false + false + 1 + + + Default Resource + [Default Resource] + + + 1000 + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + + Channel Reader + SOURCE + true + true + + + + 1 + Destination 1 + + + + false + false + 10000 + false + 0 + false + false + 1 + + false + + + Default Resource + [Default Resource] + + + 1000 + true + + none + ${message.encodedData} + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + + Channel Writer + DESTINATION + true + true + + + // Modify the message variable below to pre process data +return message; + // This script executes once after a message has been processed +// Responses returned from here will be stored as "Postprocessor" in the response map +return; + // This script executes once when the channel is deployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + // This script executes once when the channel is undeployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + + true + DEVELOPMENT + false + false + false + false + false + false + STARTED + true + + + SOURCE + STRING + mirth_source + + + TYPE + STRING + mirth_type + + + + None + + + + + Default Resource + [Default Resource] + + + + + + true + + + America/Chicago + + + true + false + + 1 + + + \ No newline at end of file diff --git a/ci/tests/101-raw-no-op/channels/01-raw-no-op/messages/01-hello-world/dest01 b/ci/tests/101-raw-no-op/channels/01-raw-no-op/messages/01-hello-world/dest01 new file mode 100644 index 0000000000..6c70ac2838 --- /dev/null +++ b/ci/tests/101-raw-no-op/channels/01-raw-no-op/messages/01-hello-world/dest01 @@ -0,0 +1 @@ +((ANY))Hello world!((ANY)) \ No newline at end of file diff --git a/ci/tests/101-raw-no-op/channels/01-raw-no-op/messages/01-hello-world/source b/ci/tests/101-raw-no-op/channels/01-raw-no-op/messages/01-hello-world/source new file mode 100644 index 0000000000..6769dd60bd --- /dev/null +++ b/ci/tests/101-raw-no-op/channels/01-raw-no-op/messages/01-hello-world/source @@ -0,0 +1 @@ +Hello world! \ No newline at end of file diff --git a/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/channel.xml b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/channel.xml new file mode 100644 index 0000000000..39db100663 --- /dev/null +++ b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/channel.xml @@ -0,0 +1,382 @@ + + 33538c13-5a0c-4399-9aca-b300a92cd712 + 2 + hl7-no-op + + 1 + + 0 + sourceConnector + + + + Auto-generate (Destinations completed) + true + false + false + 1 + + + Default Resource + [Default Resource] + + + 1000 + + + + + HL7V2 + HL7V2 + + + true + true + false + false + false + \r + true + + + false + false + \r + + + MSH_Segment + + + + \r + AA + + AE + An Error Occurred Processing Message. + AR + Message Rejected. + false + yyyyMMddHHmmss.SSS + + + AA,CA + AE,CE + AR,CR + true + Destination_Encoded + + + + + + true + true + false + false + false + \r + true + + + false + false + \r + + + MSH_Segment + + + + \r + AA + + AE + An Error Occurred Processing Message. + AR + Message Rejected. + false + yyyyMMddHHmmss.SSS + + + AA,CA + AE,CE + AR,CR + true + Destination_Encoded + + + + + + + + Channel Reader + SOURCE + true + true + + + + 1 + Destination 1 + + + + false + false + 10000 + false + 0 + false + false + 1 + + false + + + Default Resource + [Default Resource] + + + 1000 + true + + none + ${message.encodedData} + + + + + HL7V2 + HL7V2 + + + true + true + false + false + false + \r + true + + + false + false + \r + + + MSH_Segment + + + + \r + AA + + AE + An Error Occurred Processing Message. + AR + Message Rejected. + false + yyyyMMddHHmmss.SSS + + + AA,CA + AE,CE + AR,CR + true + Destination_Encoded + + + + + + true + true + false + false + false + \r + true + + + false + false + \r + + + MSH_Segment + + + + \r + AA + + AE + An Error Occurred Processing Message. + AR + Message Rejected. + false + yyyyMMddHHmmss.SSS + + + AA,CA + AE,CE + AR,CR + true + Destination_Encoded + + + + + + + HL7V2 + HL7V2 + + + true + true + false + false + false + \r + true + + + false + false + \r + + + MSH_Segment + + + + \r + AA + + AE + An Error Occurred Processing Message. + AR + Message Rejected. + false + yyyyMMddHHmmss.SSS + + + AA,CA + AE,CE + AR,CR + true + Destination_Encoded + + + + + + true + true + false + false + false + \r + true + + + false + false + \r + + + MSH_Segment + + + + \r + AA + + AE + An Error Occurred Processing Message. + AR + Message Rejected. + false + yyyyMMddHHmmss.SSS + + + AA,CA + AE,CE + AR,CR + true + Destination_Encoded + + + + + + + + Channel Writer + DESTINATION + true + true + + + // Modify the message variable below to pre process data +return message; + // This script executes once after a message has been processed +// Responses returned from here will be stored as "Postprocessor" in the response map +return; + // This script executes once when the channel is deployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + // This script executes once when the channel is undeployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + + true + DEVELOPMENT + false + false + false + false + false + false + STARTED + true + + + SOURCE + STRING + mirth_source + + + TYPE + STRING + mirth_type + + + + None + + + + + Default Resource + [Default Resource] + + + + + + true + + + America/Chicago + + + true + false + + 1 + + + \ No newline at end of file diff --git a/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/dest01_metadata.yml b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/dest01_metadata.yml new file mode 100644 index 0000000000..bb0807341d --- /dev/null +++ b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/dest01_metadata.yml @@ -0,0 +1,2 @@ +mirth_source: "Some Other App" +mirth_type: "ADT-A01" diff --git a/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/source b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/source new file mode 100644 index 0000000000..aa0a7adef1 --- /dev/null +++ b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/source @@ -0,0 +1,5 @@ +MSH|^~\&|Destination|Some Other App|Ehrsource|Or|202405221430||ADT^A01|Msgid99999|P|2.3| +EVN|A01|202405221430|| +PID|1|222333^^^2^MRN2|888777||Smith^Alice^^^^||19850412|F||Bl|456 Other St^^There^Or^97201^Usa||(503)555-1234|||S|Cat|111222333| +NK1|1|Smith^Robert^|Husband||(503)555-6789||||NK^Next Of Kin +PV1|1|I|3003^4004^02||||654321^Surgeon^Sarah^L^^Dr|||||||Emr|B1| diff --git a/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/source_response b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/source_response new file mode 100644 index 0000000000..d0124944af --- /dev/null +++ b/ci/tests/110-hl7-no-op/channels/01-hl7-no-op/messages/01-adt-a01/source_response @@ -0,0 +1,2 @@ +MSH|^~\&|Ehrsource|Or|Destination|Some Other App|((ANY))||ACK|((ANY))|P|2.3 +MSA|AA|Msgid99999 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0271f6fec4..7fd59e5386 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -141,6 +141,7 @@ jsr181-api = { module = "javax.jws:jsr181-api", version = "1.0-MR1" } jsr305 = { module = "com.google.code.findbugs:jsr305", version = "3.0.2" } junit = { module = "junit:junit", version = "4.8.1" } junit-v4-13-1 = { module = "junit:junit", version = "4.13.1" } +junit-platform-console-standalone = { module = "org.junit.platform:junit-platform-console-standalone", version = "1.11.4" } kms = { module = "software.amazon.awssdk:kms", version = "2.15.28" } libphonenumber = { module = "com.googlecode.libphonenumber:libphonenumber", version = "8.12.50" } listenablefuture = { module = "com.google.guava:listenablefuture", version = "9999.0-empty-to-avoid-conflict-with-guava" } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 6c71604422..3450217110 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -2766,6 +2766,14 @@ + + + + + + + + diff --git a/server/src/test/java/com/mirth/connect/server/launcher/Log4jMigrationsTest.java b/server/src/test/java/com/mirth/connect/server/launcher/Log4jMigrationsTest.java index 126c5f3da9..7687563c42 100644 --- a/server/src/test/java/com/mirth/connect/server/launcher/Log4jMigrationsTest.java +++ b/server/src/test/java/com/mirth/connect/server/launcher/Log4jMigrationsTest.java @@ -79,7 +79,7 @@ public void testMigrateLog4jFailsGracefullyWithReadOnlyFile() throws Exception { try { Files.setPosixFilePermissions(path, EnumSet.of(PosixFilePermission.OWNER_READ)); - assertFalse(Files.isWritable(path)); + Assume.assumeFalse("Test requires the file to be effectively non-writable", Files.isWritable(path)); System.setErr(new PrintStream(errBytes, true, StandardCharsets.UTF_8.name())); Log4jMigrations.migrateConfiguration(file); diff --git a/settings.gradle b/settings.gradle index 2d6353ad85..592d0558d7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,3 +5,4 @@ include 'server' include 'client' include 'command' include 'generator' +include 'smoketest' diff --git a/smoketest/build.gradle b/smoketest/build.gradle new file mode 100644 index 0000000000..4d121012c8 --- /dev/null +++ b/smoketest/build.gradle @@ -0,0 +1,59 @@ +// Integration ("smoke") tests that run against a live OIE server started by +// ci/runtests.sh. They are not unit tests and are deliberately absent from the +// root build/test/clean aggregates: `./gradlew build` (including the one inside +// the Dockerfile builder stage) must never try to reach a server. + +apply plugin: 'distribution' + +apply from: 'generate-smoke-tests.gradle' + +def clientCoreJar = project(':server').tasks.named('clientCoreJar') +def donkeyModelJar = project(':donkey').tasks.named('donkeyModelJar') + +dependencies { + // Client, Channel, DashboardStatus, MessageFilter, ObjectXMLSerializer + testCompileOnly files(clientCoreJar) + // RawMessage, Message, ConnectorMessage, MessageContent, Status, DeployedState + testCompileOnly files(donkeyModelJar) + // Provided at runtime by /opt/engine/server-lib (server-main uses it too). + testCompileOnly libs.snakeyaml + + testImplementation libs.junit.platform.console.standalone +} + +def generateSmokeTests = tasks.named('generateSmokeTests') + +sourceSets { + test { + java.srcDir(generateSmokeTests.flatMap { it.generatedSources }) + resources.srcDir(generateSmokeTests.flatMap { it.generatedResources }) + } +} + +// The root convention applies useJUnit() to every Test task; this module is JUnit 5. +tasks.named('test') { + useJUnitPlatform() + // Only run our tests if a live server URL is provided + // `./gradlew :smoketest:test -Doie.baseUrl=https://localhost:8443` + onlyIf { System.getProperty('oie.baseUrl') != null } + systemProperties System.getProperties().findAll { it.key.toString().startsWith('oie.') } +} + +tasks.named('jar', Jar) { + description = 'Assembles the smoke-test harness and its fixture resources.' + from sourceSets.test.output +} + +distributions { + harness { + contents { + from(tasks.named('jar')) { + into 'lib' + } + from(configurations.testRuntimeClasspath) { + into 'lib' + } + from(rootProject.file('ci/run-harness.sh')) + } + } +} diff --git a/smoketest/generate-smoke-tests.gradle b/smoketest/generate-smoke-tests.gradle new file mode 100644 index 0000000000..7ee6a2879f --- /dev/null +++ b/smoketest/generate-smoke-tests.gradle @@ -0,0 +1,174 @@ +import javax.inject.Inject + +// Generates one static JUnit test class per fixture in ci/tests/. +tasks.register('generateSmokeTests', GenerateSmokeTests) { + description = 'Generates static JUnit smoke tests and fixture resources from ci/tests.' + fixturesRoot = rootProject.layout.projectDirectory.dir('ci/tests') + generatedPackage = 'org.openintegrationengine.smoketest.cases' + generatedSources = layout.buildDirectory.dir('generated/smoketest/java') + generatedResources = layout.buildDirectory.dir('generated/smoketest/resources') +} + +/** + * Transforms the ci/tests fixture tree into static JUnit 5 sources plus the fixture + * resources they load. The mapping mirrors the directory layout so the report reads + * the same way: a class per test case, a @Nested class per channel with the channel's + * deploy/undeploy as @BeforeAll/@AfterAll, and one ordered @Test per message. + */ +abstract class GenerateSmokeTests extends DefaultTask { + + @InputDirectory + @PathSensitive(PathSensitivity.RELATIVE) + abstract DirectoryProperty getFixturesRoot() + + @Input + abstract Property getGeneratedPackage() + + @OutputDirectory + abstract DirectoryProperty getGeneratedSources() + + @OutputDirectory + abstract DirectoryProperty getGeneratedResources() + + @Inject + abstract FileSystemOperations getFsOps() + + @TaskAction + void generate() { + File root = fixturesRoot.get().asFile + File javaOut = generatedSources.get().asFile + File resOut = generatedResources.get().asFile + String pkg = generatedPackage.get() + + fsOps.delete { spec -> spec.delete(javaOut, resOut) } + File pkgDir = new File(javaOut, pkg.replace('.', '/')) + pkgDir.mkdirs() + resOut.mkdirs() + + // TreeMap keyed by File gives deterministic, lexicographic case order. + Map> byCase = new TreeMap<>() + root.traverse(type: groovy.io.FileType.FILES) { File file -> + if (file.name != 'channel.xml') return + File channelDir = file.parentFile + File channelsDir = channelDir.parentFile + if (channelsDir == null || channelsDir.name != 'channels') return + byCase.computeIfAbsent(channelsDir.parentFile, { new ArrayList() }).add(channelDir) + } + + byCase.each { caseDir, channelDirs -> writeCase(root, resOut, pkgDir, pkg, caseDir, channelDirs.sort()) } + logger.lifecycle("smoketest: generated ${byCase.size()} test case class(es) from ${root}") + } + + void writeCase(File root, File resOut, File pkgDir, String pkg, File caseDir, List channelDirs) { + String className = 'Case_' + ident(caseDir.name) + '_Test' + List configurations = readConfigurations(caseDir) + + StringBuilder out = new StringBuilder() + out << "// GENERATED by :smoketest:generateSmokeTests from ci/tests/${caseDir.name} -- DO NOT EDIT.\n" + out << "// Edit the fixtures under ci/tests/${caseDir.name} and rebuild instead.\n" + out << "package ${pkg};\n\n" + out << "import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS;\n\n" + out << "import org.junit.jupiter.api.AfterAll;\n" + out << "import org.junit.jupiter.api.BeforeAll;\n" + out << "import org.junit.jupiter.api.DisplayName;\n" + out << "import org.junit.jupiter.api.MethodOrderer;\n" + out << "import org.junit.jupiter.api.Nested;\n" + out << "import org.junit.jupiter.api.Order;\n" + out << "import org.junit.jupiter.api.Test;\n" + out << "import org.junit.jupiter.api.TestInstance;\n" + out << "import org.junit.jupiter.api.TestMethodOrder;\n\n" + out << "import org.openintegrationengine.smoketest.Harness;\n\n" + out << "@DisplayName(${quote(caseDir.name)})\n" + out << "class ${className} {\n" + channelDirs.each { channelDir -> writeChannel(out, root, resOut, channelDir, configurations) } + out << "}\n" + + new File(pkgDir, "${className}.java").setText(out.toString(), 'UTF-8') + } + + void writeChannel(StringBuilder out, File root, File resOut, File channelDir, List configurations) { + File channelXml = new File(channelDir, 'channel.xml') + String channelResource = 'fixtures/' + rel(root, channelXml) + copyResource(channelXml, new File(resOut, channelResource)) + + out << "\n @Nested\n" + out << " @DisplayName(${quote(channelDir.name)})\n" + out << " @TestInstance(PER_CLASS)\n" + out << " @TestMethodOrder(MethodOrderer.OrderAnnotation.class)\n" + out << " class Channel_${ident(channelDir.name)} {\n\n" + out << " private String channelId;\n\n" + out << " @BeforeAll\n" + out << " void deploy() throws Exception {\n" + if (!configurations.isEmpty()) { + out << " Harness.assumeConfiguration(${configurations.collect { quote(it) }.join(', ')});\n" + } + out << " channelId = Harness.deploy(${quote(channelResource)});\n" + out << " }\n\n" + out << " @AfterAll\n" + out << " void undeploy() {\n" + out << " Harness.undeploy(channelId);\n" + out << " }\n" + + File messagesDir = new File(channelDir, 'messages') + List messageDirs = messagesDir.isDirectory() ? + messagesDir.listFiles().findAll { it.isDirectory() }.sort() : [] + messageDirs.eachWithIndex { File messageDir, int index -> writeMessage(out, root, resOut, messageDir, index + 1) } + + out << " }\n" + } + + void writeMessage(StringBuilder out, File root, File resOut, File messageDir, int order) { + File source = new File(messageDir, 'source') + if (!source.isFile()) { + throw new GradleException("Message fixture is missing its source payload: ${messageDir}") + } + boolean hasSourceMap = new File(messageDir, 'source_sourcemap.yml').isFile() + String base = 'fixtures/' + rel(root, messageDir) + + List assertionFiles = [] + messageDir.listFiles().findAll { it.isFile() }.sort().each { File file -> + copyResource(file, new File(resOut, 'fixtures/' + rel(root, file))) + if (file.name != 'source' && file.name != 'source_sourcemap.yml') { + assertionFiles << file.name + } + } + + StringBuilder args = new StringBuilder("channelId, ${quote(base)}, ${hasSourceMap}") + assertionFiles.each { args << ", ${quote(it)}" } + + out << "\n @Test\n" + out << " @Order(${order})\n" + out << " @DisplayName(${quote(messageDir.name)})\n" + out << " void message_${ident(messageDir.name)}() throws Exception {\n" + out << " Harness.runMessage(${args});\n" + out << " }\n" + } + + static List readConfigurations(File caseDir) { + File file = new File(caseDir, 'configurations') + return file.isFile() ? file.readLines('UTF-8')*.trim().findAll { it && !it.startsWith('#') } : [] + } + + static void copyResource(File source, File destination) { + destination.parentFile.mkdirs() + destination.bytes = source.bytes + } + + static String rel(File root, File file) { + return root.toPath().relativize(file.toPath()).toString().replace('\\', '/') + } + + /** + * Turns a fixture directory name into the tail of a Java identifier. Every call site + * prepends a letter-led prefix (Case_/Channel_/message_), so a name that starts with a + * digit needs no further guarding here. + */ + static String ident(String name) { + return name.replaceAll('[^A-Za-z0-9]', '_') + } + + /** Quotes a string as a Java string literal. Fixture paths and names have no newlines. */ + static String quote(String value) { + return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' + } +} \ No newline at end of file diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/Harness.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/Harness.java new file mode 100644 index 0000000000..5a2b702819 --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/Harness.java @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Assumptions; + +import com.mirth.connect.donkey.model.message.ConnectorMessage; +import com.mirth.connect.donkey.model.message.Message; +import com.mirth.connect.donkey.model.message.MessageContent; +import com.mirth.connect.donkey.model.message.Status; + +/** + * The entry points the generated smoke tests call (see :smoketest:generateSmokeTests + * and the generated smoketest/build/generated/smoketest files). + */ +public final class Harness { + + /** Statuses that mean the server has not finished with the message yet. */ + private static final List PENDING_STATUSES = List.of(Status.PENDING, Status.QUEUED); + + private Harness() { + } + + private static OieServer server() { + return SharedServer.get(); + } + + /** + * Skips the enclosing channel unless the running configuration is one it targets. An + * empty configuration (a developer pointing the harness at a server by hand) runs + * everything. + */ + public static void assumeConfiguration(String... configurations) { + String configuration = HarnessConfig.CONFIGURATION; + Assumptions.assumeTrue( + configuration == null || configuration.isBlank() + || Arrays.asList(configurations).contains(configuration), + () -> "fixture is not enabled for configuration '" + configuration + "'"); + } + + /** Deploys the channel exported at {@code channelResource} and returns its id. */ + public static String deploy(String channelResource) throws Exception { + return server().deployChannel(resource(channelResource), channelResource); + } + + /** Undeploys and removes a channel, tolerating a null id left by a failed deploy. */ + public static void undeploy(String channelId) { + if (channelId != null) { + server().removeChannel(channelId); + } + } + + /** + * Submits {@code /source} (with {@code /source_sourcemap.yml} when + * {@code hasSourceMap}) into the channel, then retries the named assertion files until + * they all hold or the message reaches a terminal state. Because the message is written + * asynchronously, an early poll can legitimately fail; only a failure that persists once + * the message is terminal is a real failure. + */ + public static void runMessage(String channelId, String base, boolean hasSourceMap, String... assertionFiles) + throws Exception { + String source = resource(base + "/source"); + Map sourceMap = hasSourceMap + ? MessageAssertions.parseSourceMap(resource(base + "/source_sourcemap.yml")) + : new LinkedHashMap<>(); + + // Load the fixtures once; the poll loop below may check them many times. + Map assertions = new LinkedHashMap<>(); + for (String fileName : assertionFiles) { + assertions.put(fileName, resource(base + "/" + fileName)); + } + + long messageId = server().submitMessage(channelId, source, sourceMap); + + long deadline = System.nanoTime() + HarnessConfig.TIMEOUT.toNanos(); + AssertionError lastFailure = null; + Message lastMessage = null; + while (System.nanoTime() < deadline) { + Message message = server().fetchMessage(channelId, messageId); + if (message != null) { + lastMessage = message; + try { + for (Map.Entry assertion : assertions.entrySet()) { + MessageAssertions.assertFixtureFile(message, assertion.getKey(), assertion.getValue()); + } + return; + } catch (AssertionError e) { + lastFailure = e; + if (isTerminal(message)) { + break; + } + } + } + Thread.sleep(500); + } + + if (lastFailure != null) { + throw new AssertionError(base + " failed: " + lastFailure.getMessage() + + "\n\n" + describe(lastMessage), lastFailure); + } + throw new AssertionError("Timed out after " + HarnessConfig.TIMEOUT.toSeconds() + "s waiting for message " + + messageId + " for fixture " + base + "\n\n" + describe(lastMessage)); + } + + /** Reads a staged fixture from the classpath. */ + static String resource(String path) { + try (InputStream in = Harness.class.getClassLoader().getResourceAsStream(path)) { + if (in == null) { + throw new IllegalStateException("Missing fixture resource on the classpath: " + path); + } + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Could not read fixture resource " + path, e); + } + } + + /** True once the server has finished processing and no connector is still pending. */ + private static boolean isTerminal(Message message) { + if (!message.isProcessed()) { + return false; + } + Map connectorMessages = message.getConnectorMessages(); + if (connectorMessages == null) { + return false; + } + return connectorMessages.values().stream() + .noneMatch(connectorMessage -> PENDING_STATUSES.contains(connectorMessage.getStatus())); + } + + /** Renders the message the way a fixture author needs to see it to fix a mismatch. */ + private static String describe(Message message) { + if (message == null) { + return "No message was retrieved from the server."; + } + + StringBuilder detail = new StringBuilder("Actual message ").append(message.getMessageId()) + .append(" (processed=").append(message.isProcessed()).append("):"); + Map connectorMessages = message.getConnectorMessages(); + if (connectorMessages == null) { + return detail.append("\n ").toString(); + } + + connectorMessages.entrySet().stream().sorted(Map.Entry.comparingByKey()).forEach(entry -> { + ConnectorMessage connectorMessage = entry.getValue(); + detail.append("\n [").append(entry.getKey()).append("] ").append(connectorMessage.getConnectorName()) + .append(" status=").append(connectorMessage.getStatus()); + appendContent(detail, "raw", connectorMessage.getRaw()); + appendContent(detail, "transformed", connectorMessage.getTransformed()); + appendContent(detail, "encoded", connectorMessage.getEncoded()); + appendContent(detail, "sent", connectorMessage.getSent()); + appendContent(detail, "response", connectorMessage.getResponse()); + detail.append("\n connectorMap=").append(connectorMessage.getConnectorMap()) + .append("\n metaDataMap=").append(connectorMessage.getMetaDataMap()); + if (connectorMessage.getProcessingError() != null) { + detail.append("\n processingError=").append(connectorMessage.getProcessingError()); + } + }); + return detail.toString(); + } + + private static void appendContent(StringBuilder detail, String label, MessageContent content) { + if (content != null && content.getContent() != null) { + detail.append("\n ").append(label).append('=').append(quote(content.getContent())); + } + } + + private static String quote(String content) { + String escaped = content.replace("\r\n", "\\n").replace("\r", "\\n").replace("\n", "\\n"); + return "\"" + (escaped.length() > 2000 ? escaped.substring(0, 2000) + "\"... (truncated)" : escaped + "\""); + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/HarnessConfig.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/HarnessConfig.java new file mode 100644 index 0000000000..3fa9e434c1 --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/HarnessConfig.java @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import java.time.Duration; + +/** + * System properties the harness is driven by. ci/run-harness.sh sets these inside the + * harness container; a developer running from an IDE or from + * {@code ./gradlew :smoketest:test} passes them with {@code -Doie.*}. + */ +final class HarnessConfig { + + /** Base URL of the server under test, e.g. {@code https://oie:8443}. Required. */ + static final String BASE_URL = require("oie.baseUrl"); + + static final String USERNAME = System.getProperty("oie.username", "admin"); + static final String PASSWORD = System.getProperty("oie.password", "admin"); + + /** + * Name of the compose configuration being exercised, used to honour each test + * directory's optional {@code configurations} file. Empty means "unfiltered": every + * discovered test runs regardless of what it declares. That is the useful default for + * a developer pointing the harness at a server by hand. + */ + static final String CONFIGURATION = System.getProperty("oie.configuration", ""); + + /** Ceiling on waiting for a channel to start or a message to reach its asserted state. */ + static final Duration TIMEOUT = + Duration.ofSeconds(Long.parseLong(System.getProperty("oie.timeoutSeconds", "90"))); + + /** + * Per-request socket timeout. {@code new Client(address)} defaults to an infinite + * timeout, which would let a wedged server hang CI instead of failing it. + */ + static final int REQUEST_TIMEOUT_MILLIS = + Integer.parseInt(System.getProperty("oie.requestTimeoutMillis", "15000")); + + private HarnessConfig() { + } + + private static String require(String key) { + String value = System.getProperty(key); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Required system property " + key + " is not set"); + } + return value; + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/MessageAssertions.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/MessageAssertions.java new file mode 100644 index 0000000000..f9849435ae --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/MessageAssertions.java @@ -0,0 +1,239 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.yaml.snakeyaml.Yaml; + +import com.mirth.connect.donkey.model.message.ConnectorMessage; +import com.mirth.connect.donkey.model.message.Message; +import com.mirth.connect.donkey.model.message.MessageContent; +import com.mirth.connect.donkey.model.message.Response; +import com.mirth.connect.donkey.model.message.Status; +import com.mirth.connect.model.converters.ObjectXMLSerializer; + +/** + * Fixture-file assertions against a retrieved {@link Message}. + * + *

Every value compared here comes off a typed model object, so there is no XML parsing: + * statuses are {@link Status} enums, content comes from {@link MessageContent#getContent()}, + * and metadata comes from {@link ConnectorMessage#getMetaDataMap()}. The generator supplies + * each fixture file's name and its already-loaded text; the name selects the connector and + * the kind of assertion exactly as ci/README.md describes. + */ +final class MessageAssertions { + + /** Fixture wildcard: matches any run of characters, for timestamps and generated ids. */ + private static final String ANY_WILDCARD = "((ANY))"; + + private static final Pattern RESPONSE_ENVELOPE = Pattern.compile("^\\s*].*", Pattern.DOTALL); + + /** Destination assertion files are {@code dest} plus an optional suffix. */ + private static final Pattern DEST_NAME = Pattern.compile("dest(\\d+)(_transformed|_response|_status|_metadata\\.yml)?"); + + /** Source connector metadata id; destination N is metadata id N. */ + private static final int SOURCE_META_DATA_ID = 0; + + private MessageAssertions() { + } + + /** + * Applies one fixture file's assertion to the message. + * + * @param fileName the fixture file name, e.g. {@code source_status} or {@code dest01} + * @param content that file's text, already loaded from the classpath + */ + static void assertFixtureFile(Message message, String fileName, String content) { + switch (fileName) { + case "source_status" -> assertStatus("source status", content, + connector(message, SOURCE_META_DATA_ID, fileName).getStatus()); + case "source_transformed" -> assertContent("source transformed", content, + content(connector(message, SOURCE_META_DATA_ID, fileName).getTransformed())); + case "source_response" -> assertResponse("source response", content, + connector(message, SOURCE_META_DATA_ID, fileName)); + case "source_metadata.yml" -> assertMetadata("source_metadata.yml", parseYamlMap(content), + connector(message, SOURCE_META_DATA_ID, fileName)); + default -> assertDestination(message, fileName, content); + } + } + + private static void assertDestination(Message message, String fileName, String content) { + Matcher matcher = DEST_NAME.matcher(fileName); + if (!matcher.matches()) { + throw new IllegalStateException("Unrecognised fixture file name: " + fileName); + } + + int metaDataId = Integer.parseInt(matcher.group(1)); + String suffix = matcher.group(2) == null ? "" : matcher.group(2); + ConnectorMessage destination = connector(message, metaDataId, fileName); + + switch (suffix) { + case "" -> assertContent(fileName, content, content(destination.getSent())); + case "_transformed" -> assertContent(fileName, content, content(destination.getTransformed())); + case "_response" -> assertResponse(fileName, content, destination); + case "_status" -> assertStatus(fileName, content, destination.getStatus()); + case "_metadata.yml" -> assertMetadata(fileName, parseYamlMap(content), destination); + default -> throw new IllegalStateException("Unhandled fixture suffix: " + suffix); + } + } + + /** + * The source map is serialized by XStream on its way to the server, which cannot convert + * the JDK's immutable map implementations (and would need + * {@code --add-opens java.base/java.util} to try), so always hand back a plain mutable map. + */ + static Map parseSourceMap(String content) { + return new LinkedHashMap<>(parseYamlMap(content)); + } + + private static ConnectorMessage connector(Message message, int metaDataId, String label) { + Map connectorMessages = message.getConnectorMessages(); + ConnectorMessage connectorMessage = connectorMessages == null ? null : connectorMessages.get(metaDataId); + if (connectorMessage == null) { + throw new AssertionError("Message " + message.getMessageId() + " has no connector with metadata id " + + metaDataId + " (needed by " + label + "); present ids: " + + (connectorMessages == null ? "none" : connectorMessages.keySet())); + } + return connectorMessage; + } + + private static void assertStatus(String label, String expectedText, Status actual) { + String expected = expectedText.trim(); + if (actual == null || !expected.equals(actual.name())) { + throw new AssertionError("Expected " + label + " to be " + expected + ", found " + actual); + } + } + + private static void assertContent(String label, String expected, String actual) { + assertMatches(label, expected, actual); + } + + /** + * Response content is a serialised {@link Response}, so unwrap it to the payload the + * fixture actually describes. Line endings are normalised because HL7 acknowledgements + * come back CR-delimited while the fixture files are LF-delimited. + */ + private static void assertResponse(String label, String expected, ConnectorMessage connectorMessage) { + String stored = content(connectorMessage.getResponse()); + String actual = stored; + if (stored != null && RESPONSE_ENVELOPE.matcher(stored).matches()) { + Response response = ObjectXMLSerializer.getInstance().deserialize(stored.trim(), Response.class); + String payload = response == null ? null : response.getMessage(); + actual = payload == null ? null : payload.replace("\r\n", "\n").replace('\r', '\n'); + } + assertMatches(label, expected, actual); + } + + /** + * Metadata assertions are a subset check: the fixture lists only the keys it cares about. + * Custom metadata columns land in the connector map and message metadata map, so both are + * consulted, with the metadata map winning on conflict. + */ + private static void assertMetadata(String label, Map expected, ConnectorMessage connectorMessage) { + Map actual = new LinkedHashMap<>(); + putAll(actual, connectorMessage.getConnectorMap()); + putAll(actual, connectorMessage.getMetaDataMap()); + assertSubset(label, "", expected, actual); + } + + private static void putAll(Map target, Map source) { + if (source != null) { + target.putAll(source); + } + } + + @SuppressWarnings("unchecked") + private static void assertSubset(String label, String path, Map expected, + Map actual) { + for (Map.Entry entry : expected.entrySet()) { + String keyPath = path.isEmpty() ? entry.getKey() : path + "." + entry.getKey(); + if (!actual.containsKey(entry.getKey())) { + throw new AssertionError("Metadata mismatch for " + label + ": missing key " + keyPath + + "; present keys: " + actual.keySet()); + } + + Object expectedValue = entry.getValue(); + Object actualValue = actual.get(entry.getKey()); + if (expectedValue instanceof Map expectedMap) { + if (!(actualValue instanceof Map actualMap)) { + throw new AssertionError("Metadata mismatch for " + label + " at " + keyPath + + ": expected a mapping, found " + describe(actualValue)); + } + assertSubset(label, keyPath, (Map) expectedMap, (Map) actualMap); + } else if (!scalarsEqual(expectedValue, actualValue)) { + throw new AssertionError("Metadata mismatch for " + label + " at " + keyPath + ": expected " + + describe(expectedValue) + ", found " + describe(actualValue)); + } + } + } + + /** + * YAML gives Strings, Integers and Booleans while the server may return any of those, so + * compare by string form rather than by type. + */ + private static boolean scalarsEqual(Object expected, Object actual) { + if (expected == null || actual == null) { + return Objects.equals(expected, actual); + } + return String.valueOf(expected).equals(String.valueOf(actual)); + } + + /** Compares an assertion file to actual content, honouring {@value #ANY_WILDCARD}. */ + private static void assertMatches(String label, String expected, String actual) { + if (actual == null) { + throw new AssertionError("Expected " + label + " content but the server stored none"); + } + if (!toPattern(expected).matcher(actual).matches()) { + throw new AssertionError("Content mismatch for " + label + + "\n expected: " + describe(expected) + + "\n actual: " + describe(actual)); + } + } + + /** Quotes the fixture text, leaving {@value #ANY_WILDCARD} as a lazy match-anything. */ + private static Pattern toPattern(String expected) { + StringBuilder regex = new StringBuilder(); + int position = 0; + while (true) { + int wildcard = expected.indexOf(ANY_WILDCARD, position); + if (wildcard < 0) { + break; + } + if (wildcard > position) { + regex.append(Pattern.quote(expected.substring(position, wildcard))); + } + regex.append(".*?"); + position = wildcard + ANY_WILDCARD.length(); + } + if (position < expected.length()) { + regex.append(Pattern.quote(expected.substring(position))); + } + return Pattern.compile(regex.toString(), Pattern.DOTALL); + } + + private static String content(MessageContent messageContent) { + return messageContent == null ? null : messageContent.getContent(); + } + + private static String describe(Object value) { + return value == null ? "" : "\"" + value + "\""; + } + + @SuppressWarnings("unchecked") + private static Map parseYamlMap(String content) { + Object loaded = new Yaml().load(content); + if (loaded == null) { + return Map.of(); + } + if (!(loaded instanceof Map map)) { + throw new IllegalStateException("Expected a YAML mapping but found: " + content); + } + return (Map) map; + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/OieServer.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/OieServer.java new file mode 100644 index 0000000000..51ded95d85 --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/OieServer.java @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.List; +import java.util.Map; + +import com.mirth.connect.client.core.Client; +import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.donkey.model.channel.DeployedState; +import com.mirth.connect.donkey.model.message.Message; +import com.mirth.connect.donkey.model.message.RawMessage; +import com.mirth.connect.model.Channel; +import com.mirth.connect.model.LoginStatus; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.model.filters.MessageFilter; +import com.mirth.connect.util.MirthSSLUtil; + +/** + * The harness's view of a live server: log in, deploy a channel fixture, submit a message, + * read the resulting message back. + * + *

This is a thin façade over {@link Client}, which already handles everything the + * previous Python runner had to hand-roll: it trusts the server's self-signed certificate + * ({@code TrustSelfSignedStrategy} plus {@code NoopHostnameVerifier}), sends the mandatory + * {@code X-Requested-With} header, keeps the session cookie, and serialises the OIE model + * classes. Assertions therefore run against typed objects rather than scraped XML. + */ +final class OieServer implements AutoCloseable { + + private static boolean serializerInitialized; + + private final Client client; + /** Deployed channel ids, newest first, so teardown unwinds in reverse order. */ + private final Deque deployedChannelIds = new ArrayDeque<>(); + + private OieServer(Client client) { + this.client = client; + } + + static OieServer connect() { + Client client; + try { + client = new Client(HarnessConfig.BASE_URL, HarnessConfig.REQUEST_TIMEOUT_MILLIS, + MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES); + } catch (Exception e) { + throw new IllegalStateException("Could not create a client for " + HarnessConfig.BASE_URL, e); + } + + try { + LoginStatus status = client.login(HarnessConfig.USERNAME, HarnessConfig.PASSWORD); + if (status == null || !status.isSuccess()) { + throw new IllegalStateException("Login as " + HarnessConfig.USERNAME + " failed: " + + (status == null ? "no response" : status.getStatus() + " " + status.getMessage())); + } + initSerializer(client.getVersion()); + } catch (RuntimeException e) { + client.close(); + throw e; + } catch (Exception e) { + client.close(); + throw new IllegalStateException("Could not log in to " + HarnessConfig.BASE_URL, e); + } + + return new OieServer(client); + } + + /** + * {@code ObjectXMLSerializer.init} throws if called twice, and the singleton outlives + * any one connection, so initialise it at most once per JVM. + */ + private static synchronized void initSerializer(String serverVersion) throws Exception { + if (!serializerInitialized) { + ObjectXMLSerializer.getInstance().init(serverVersion); + serializerInitialized = true; + } + } + + /** + * Deploys an exported channel and waits for it to reach {@link DeployedState#STARTED}. + * + * @param xml the exported channel XML + * @param label a human-readable name for the channel, used only in error messages + * @return the deployed channel's id + */ + String deployChannel(String xml, String label) throws Exception { + Channel channel = ObjectXMLSerializer.getInstance().deserialize(xml, Channel.class); + String channelId = channel.getId(); + if (channelId == null || channelId.isBlank()) { + throw new IllegalArgumentException("Channel fixture has no id: " + label); + } + + // A previous run may have left the channel behind; overwrite rather than fail. + if (!client.createChannel(channel)) { + client.updateChannel(channel, true, null); + } + deployedChannelIds.push(channelId); + + // returnErrors=true so a deploy failure surfaces here instead of only as a status + // that never reaches STARTED. The String overload avoids DebuggerUtil parsing. + client.deployChannel(channelId, true, ""); + awaitStarted(channelId, label); + return channelId; + } + + private void awaitStarted(String channelId, String label) throws Exception { + long deadline = System.nanoTime() + HarnessConfig.TIMEOUT.toNanos(); + DeployedState lastState = null; + while (System.nanoTime() < deadline) { + var status = client.getChannelStatus(channelId); + lastState = status == null ? null : status.getState(); + if (lastState == DeployedState.STARTED) { + return; + } + Thread.sleep(500); + } + throw new AssertionError("Channel " + label + " (" + channelId + ") did not start within " + + HarnessConfig.TIMEOUT.toSeconds() + "s; last state was " + lastState); + } + + /** Submits a source payload and returns the new message id. */ + long submitMessage(String channelId, String rawData, Map sourceMap) throws ClientException { + RawMessage rawMessage = new RawMessage(rawData, null, sourceMap); + Long messageId = client.processMessage(channelId, rawMessage); + if (messageId == null) { + throw new AssertionError("Server returned no message id for channel " + channelId); + } + return messageId; + } + + /** Reads one message back, with content, so assertions can inspect every connector. */ + Message fetchMessage(String channelId, long messageId) throws ClientException { + MessageFilter filter = new MessageFilter(); + filter.setMinMessageId(messageId); + filter.setMaxMessageId(messageId); + + List messages = client.getMessages(channelId, filter, true, 0, 1); + if (messages == null || messages.isEmpty()) { + return null; + } + return messages.get(0); + } + + /** Undeploys and removes a channel, tolerating failures so teardown always continues. */ + void removeChannel(String channelId) { + deployedChannelIds.remove(channelId); + tolerate("undeploy", channelId, () -> client.undeployChannel(channelId, false)); + tolerate("remove", channelId, () -> client.removeChannel(channelId)); + } + + @Override + public void close() { + // Safety net for channels whose per-channel teardown never ran. + while (!deployedChannelIds.isEmpty()) { + removeChannel(deployedChannelIds.peek()); + } + try { + client.logout(); + } catch (Exception e) { + System.err.println("Ignoring logout failure: " + e); + } + client.close(); + } + + private void tolerate(String action, String channelId, ClientAction body) { + try { + body.run(); + } catch (Exception e) { + System.err.println("Ignoring " + action + " failure for channel " + channelId + ": " + e); + } + } + + private interface ClientAction { + void run() throws ClientException, IOException; + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/SharedServer.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/SharedServer.java new file mode 100644 index 0000000000..33dd192fda --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/SharedServer.java @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +/** + * The one server connection shared by every generated test class in a JVM. The JUnit + * launcher runs one configuration per JVM and then exits, so a single login is reused + * for the whole run and closed by a shutdown hook rather than per test class. + */ +final class SharedServer { + + private static OieServer instance; + + private SharedServer() { + } + + static synchronized OieServer get() { + if (instance == null) { + OieServer server = OieServer.connect(); + Runtime.getRuntime().addShutdownHook(new Thread(server::close, "oie-smoketest-logout")); + instance = server; + } + return instance; + } +} diff --git a/tools/gen-dockerignore.sh b/tools/gen-dockerignore.sh new file mode 100755 index 0000000000..fb866a4b13 --- /dev/null +++ b/tools/gen-dockerignore.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Generate .dockerignore from .gitignore. +# +# .gitignore is the authoritative list of build outputs and local artifacts. The +# Dockerfile builds via `COPY . .`, so anything git ignores (and that a clean CI +# checkout therefore never has) must also be kept out of the image build context; +# otherwise stale local artifacts leak into the image (this is how a leftover +# server/setup/appdata/keystore.jks once broke TLS startup). +# +# .gitignore and Docker's .dockerignore use *opposite* default anchoring: +# +# git: a pattern with no leading/embedded slash matches at ANY depth +# (`build/` matches `client/build/`). +# docker: a pattern is matched against the full path from the context root +# (`build/` matches only root `build`; any depth needs `**/build`). +# +# This script translates the anchoring so the two stay equivalent. Edit +# .gitignore, then re-run: tools/gen-dockerignore.sh +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +GITIGNORE="$ROOT_DIR/.gitignore" +DOCKERIGNORE="$ROOT_DIR/.dockerignore" + +# Excludes that are NOT build outputs (so they are not in .gitignore) but that the +# image build does not need. Keeping them out shrinks the context and avoids busting +# the build cache on unrelated changes. +docker_specific=( + ".git" # VCS metadata; never auto-excluded by Docker + ".github/" # CI config, not needed inside the image + "**/Dockerfile*" + "**/.dockerignore" +) + +{ + cat <<'EOF' +# GENERATED FROM .gitignore by tools/gen-dockerignore.sh -- DO NOT EDIT BY HAND. +# .gitignore is authoritative; edit it and re-run the generator. +# (git and docker use opposite default path anchoring -- see the script.) + +EOF + + # sed drops the many empty "# /path/" section-placeholder comments that pad + # .gitignore, then squeezes the blank runs they leave behind. Banner and section + # comments are kept for readability. + sed -e '/^[[:space:]]*#[[:space:]]*\//d' -e 's/[[:space:]]*$//' "$GITIGNORE" \ + | cat -s \ + | while IFS= read -r line; do + # Blank lines and comments pass through untouched. + if [[ -z "$line" || "$line" == \#* ]]; then + printf '%s\n' "$line" + continue + fi + + # A leading '!' re-includes; translate the rest and put it back. + negate="" + if [[ "$line" == '!'* ]]; then + negate="!" + line="${line#!}" + fi + + # dockerignore matches a directory by its name, without a trailing slash. + line="${line%/}" + [[ -z "$line" ]] && continue + + case "$line" in + /*) translated="${line#/}" ;; # git root-anchored -> docker root-relative + */*) translated="$line" ;; # embedded slash: already anchored in both + *) translated="**/$line" ;; # git "any depth" -> explicit in docker + esac + + printf '%s%s\n' "$negate" "$translated" + done + + printf '\n# ---- Docker-specific (not build outputs, so not in .gitignore) ----\n' + printf '%s\n' "${docker_specific[@]}" +} > "$DOCKERIGNORE" + +echo "Wrote ${DOCKERIGNORE#"$ROOT_DIR"/}"