From d3d06e8fe14d2604a0dbe599d7d0ff63cadb22e4 Mon Sep 17 00:00:00 2001 From: Mitch Gaffigan Date: Sat, 26 Sep 2026 01:58:32 +0000 Subject: [PATCH 1/6] Fix command hang when the server is unreachable The client's connection monitor is a non-daemon thread, and runShell only closed the client on success. A refused connection, rejected certificate, or failed login printed the error and then hung forever. Signed-off-by: Mitch Gaffigan --- .../java/com/mirth/connect/cli/CommandLineInterface.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java b/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java index 3f757d4ca1..483a48c5e6 100644 --- a/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java +++ b/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java @@ -216,7 +216,6 @@ private void runShell(String server, String user, String password, String script runConsole(); } client.logout(); - client.close(); out.println("Disconnected from server."); } catch (ClientException ce) { ce.printStackTrace(); @@ -224,6 +223,12 @@ private void runShell(String server, String user, String password, String script error("Could not load script file.", ioe); } catch (URISyntaxException e) { error("Invalid server address.", e); + } finally { + // The client's connection monitor is a non-daemon thread, so an unclosed + // client keeps the JVM alive instead of letting it exit. + if (client != null) { + client.close(); + } } } From 38705e815b1b1ee54711cd4dc12f400622ec1fe1 Mon Sep 17 00:00:00 2001 From: Mitch Gaffigan Date: Sat, 26 Sep 2026 01:59:21 +0000 Subject: [PATCH 2/6] Smoke test command from CI Nothing covered command end to end. The harness image now carries the CLI and runs it as a child process, so the distribution layout and launcher manifest are covered too. Signed-off-by: Mitch Gaffigan --- Dockerfile | 4 + ci/run-harness.sh | 1 + .../smoketest/CommandLineClient.java | 104 ++++++++++++++++++ .../smoketest/CommandLineInterfaceTest.java | 93 ++++++++++++++++ .../smoketest/OieServer.java | 5 + 5 files changed, 207 insertions(+) create mode 100644 smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineClient.java create mode 100644 smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineInterfaceTest.java diff --git a/Dockerfile b/Dockerfile index 90709a63b6..a59672f5ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,10 @@ 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/server/setup/conf /opt/engine/conf +# The launcher resolves cli-lib/ relative to its working directory, so the CLI has +# to keep the layout it has in the distribution. +COPY --from=builder /app/server/setup/cli-lib /opt/engine/cli-lib +COPY --from=builder /app/server/setup/mirth-cli-launcher.jar /opt/engine/mirth-cli-launcher.jar COPY --from=builder /app/smoketest/build/install/smoketest-harness /harness ENTRYPOINT ["/bin/bash", "/harness/run-harness.sh"] diff --git a/ci/run-harness.sh b/ci/run-harness.sh index a1582525bb..202d40b208 100755 --- a/ci/run-harness.sh +++ b/ci/run-harness.sh @@ -20,6 +20,7 @@ java \ -Doie.baseUrl="$OIE_BASE_URL" \ -Doie.configuration="$OIE_CONFIGURATION" \ -Doie.password="$OIE_PASSWORD" \ + -Doie.cliHome="$ENGINE_HOME" \ ${OIE_DB_DRIVER:+-Doie.db.driver="$OIE_DB_DRIVER"} \ ${OIE_DB_URL:+-Doie.db.url="$OIE_DB_URL"} \ ${OIE_DB_USERNAME:+-Doie.db.username="$OIE_DB_USERNAME"} \ diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineClient.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineClient.java new file mode 100644 index 0000000000..a0e6c85be7 --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineClient.java @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Runs the packaged command-line client ({@code mirth-cli-launcher.jar}) as a child + * process. + * + *

The launcher resolves {@code cli-lib/} and {@code ./extensions} relative to its + * working directory and reads its log configuration from {@code conf/} via its manifest + * {@code Class-Path}, so it only runs from a staged distribution. + */ +final class CommandLineClient { + + /** Distribution root; ci/run-harness.sh points this at the tree in the harness image. */ + private static final Path HOME = Path.of(System.getProperty("oie.cliHome", "/opt/engine")); + + private static final long TIMEOUT_SECONDS = HarnessConfig.TIMEOUT.toSeconds(); + + private CommandLineClient() { + } + + /** Runs the CLI against the server under test, feeding it {@code statements} as a script. */ + static Result runScript(String... statements) throws Exception { + return runScriptAgainst(HarnessConfig.BASE_URL, HarnessConfig.USERNAME, HarnessConfig.PASSWORD, + statements); + } + + /** As {@link #runScript}, against an address and credentials of the caller's choosing. */ + static Result runScriptAgainst(String address, String user, String password, String... statements) + throws Exception { + Path script = Files.createTempFile("oie-cli-", ".script"); + try { + Files.writeString(script, String.join("\n", statements) + "\n", StandardCharsets.UTF_8); + return run("-a", address, "-u", user, "-p", password, "-s", script.toString()); + } finally { + Files.deleteIfExists(script); + } + } + + /** Runs the CLI with exactly {@code args}. */ + static Result run(String... args) throws Exception { + Path launcher = HOME.resolve("mirth-cli-launcher.jar"); + if (!Files.isRegularFile(launcher) || !Files.isRegularFile(HOME.resolve("cli-lib/mirth-cli.jar"))) { + throw new AssertionError("No command-line client staged at " + HOME + + "; the harness image is built to carry one (see the Dockerfile" + + " smoketest-harness target) and ci/run-harness.sh sets oie.cliHome."); + } + + List command = new ArrayList<>(); + command.add(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + command.add("-jar"); + command.add(launcher.getFileName().toString()); + command.addAll(Arrays.asList(args)); + + // A file rather than a pipe: reading a pipe would block forever on a CLI that + // never exits, so the timeout below could never fire. + Path outputFile = Files.createTempFile("oie-cli-", ".out"); + try { + Process process = new ProcessBuilder(command) + .directory(HOME.toFile()) + .redirectErrorStream(true) + .redirectOutput(outputFile.toFile()) + .start(); + + // Without -s the CLI reads stdin; close it so such a run cannot wait forever. + process.getOutputStream().close(); + + boolean exited; + try { + exited = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } finally { + process.destroyForcibly(); + } + + String output = Files.readString(outputFile, StandardCharsets.UTF_8); + if (!exited) { + throw new AssertionError("The CLI did not exit within " + TIMEOUT_SECONDS + "s: " + + String.join(" ", command) + "\n--- output ---\n" + output + "--- end output ---"); + } + return new Result(command, process.exitValue(), output); + } finally { + Files.deleteIfExists(outputFile); + } + } + + record Result(List command, int exitCode, String output) { + + @Override + public String toString() { + return "exit=" + exitCode + " from " + String.join(" ", command) + + "\n--- output ---\n" + output + "--- end output ---"; + } + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineInterfaceTest.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineInterfaceTest.java new file mode 100644 index 0000000000..1fe8212e12 --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineInterfaceTest.java @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Smoke tests for the packaged command-line client, run as a child process against the + * live server. + * + *

Assertions are on output rather than on the exit code: the CLI exits 0 whether or + * not it could log in or run the statements it was given. + */ +@DisplayName("Command-line client") +class CommandLineInterfaceTest { + + @Test + @DisplayName("prints usage for -h without contacting a server") + void printsUsageForHelp() throws Exception { + CommandLineClient.Result result = CommandLineClient.run("-h"); + + assertEquals(0, result.exitCode(), () -> "-h should exit 0, got " + result); + assertTrue(result.output().contains("usage: Shell"), () -> "no usage text in " + result); + + for (String option : List.of("-a

", "-u ", "-p ", + "-s