From 397827c7628839c8282ec7e8ecb6d030eaa86bf3 Mon Sep 17 00:00:00 2001 From: Christoph Deppisch Date: Wed, 11 Sep 2024 10:37:02 +0200 Subject: [PATCH] fix(#1049): Add JBang support - Add new connector module to run JBang from Citrus tests - Adds a new JBang test action to run a JBang script with a spawned process - Include XML and YAML test DSL support for the JBang test action - Install JBang in CI workflows --- .github/workflows/build.yml | 11 +- .github/workflows/lts.yml | 11 +- catalog/citrus-bom/pom.xml | 5 + connectors/citrus-jbang-connector/pom.xml | 54 ++ .../citrusframework/jbang/JBangSettings.java | 106 ++++ .../citrusframework/jbang/JBangSupport.java | 466 ++++++++++++++++++ .../jbang/ProcessAndOutput.java | 168 +++++++ .../jbang/actions/JBangAction.java | 272 ++++++++++ .../org/citrusframework/jbang/xml/JBang.java | 220 +++++++++ .../jbang/xml/ObjectFactory.java | 52 ++ .../jbang/xml/package-info.java | 18 + .../org/citrusframework/jbang/yaml/JBang.java | 154 ++++++ .../src/main/resources/META-INF/LICENSE.txt | 201 ++++++++ .../src/main/resources/META-INF/NOTICE.txt | 32 ++ .../META-INF/citrus/xml/builder/jbang | 2 + .../META-INF/citrus/yaml/builder/jbang | 1 + .../jbang/UnitTestSupport.java | 40 ++ .../jbang/actions/JBangActionTest.java | 117 +++++ .../jbang/xml/AbstractXmlActionTest.java | 76 +++ .../citrusframework/jbang/xml/JBangTest.java | 75 +++ .../jbang/yaml/AbstractYamlActionTest.java | 89 ++++ .../citrusframework/jbang/yaml/JBangTest.java | 75 +++ .../src/test/resources/log4j2-test.xml | 55 +++ .../context/citrus-unit-context.xml | 7 + .../org/citrusframework/jbang/hello.java | 22 + .../citrusframework/jbang/xml/jbang-test.xml | 36 ++ .../jbang/yaml/jbang-test.yaml | 25 + connectors/pom.xml | 1 + .../citrusframework/actions/EchoAction.java | 2 +- pom.xml | 9 +- .../citrus-testcase-4.4.0-SNAPSHOT.xsd | 40 ++ .../schema/xml/testcase/citrus-testcase.xsd | 40 ++ src/main/assembly/dist-antlibs.xml | 1 + src/main/assembly/dist-release.xml | 8 + src/main/assembly/dist-sources.xml | 7 + src/manual/connector-jbang.adoc | 89 ++++ src/manual/connectors.adoc | 1 + 37 files changed, 2584 insertions(+), 4 deletions(-) create mode 100644 connectors/citrus-jbang-connector/pom.xml create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSettings.java create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSupport.java create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/ProcessAndOutput.java create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/actions/JBangAction.java create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/JBang.java create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/ObjectFactory.java create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/package-info.java create mode 100644 connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/yaml/JBang.java create mode 100644 connectors/citrus-jbang-connector/src/main/resources/META-INF/LICENSE.txt create mode 100644 connectors/citrus-jbang-connector/src/main/resources/META-INF/NOTICE.txt create mode 100644 connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/xml/builder/jbang create mode 100644 connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/yaml/builder/jbang create mode 100644 connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/UnitTestSupport.java create mode 100644 connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/actions/JBangActionTest.java create mode 100644 connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/AbstractXmlActionTest.java create mode 100644 connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/JBangTest.java create mode 100644 connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/AbstractYamlActionTest.java create mode 100644 connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/JBangTest.java create mode 100644 connectors/citrus-jbang-connector/src/test/resources/log4j2-test.xml create mode 100644 connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/context/citrus-unit-context.xml create mode 100644 connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/hello.java create mode 100644 connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/xml/jbang-test.xml create mode 100644 connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/yaml/jbang-test.yaml create mode 100644 src/manual/connector-jbang.adoc diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f41cd3612a..b5fbd8ecfc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -56,4 +56,13 @@ jobs: java -version ./mvnw -version - name: Build Citrus - run: ./mvnw --no-transfer-progress -T1C install + run: | + echo "Install JBang via SDKMAN" + + curl -s "https://get.sdkman.io" | bash + source "/home/runner/.sdkman/bin/sdkman-init.sh" + sdk install jbang + + jbang --version + + ./mvnw --no-transfer-progress -T1C install diff --git a/.github/workflows/lts.yml b/.github/workflows/lts.yml index 18fa8a3fd8..d9c1060c88 100644 --- a/.github/workflows/lts.yml +++ b/.github/workflows/lts.yml @@ -48,4 +48,13 @@ jobs: java -version ./mvnw -version - name: Build Citrus - run: ./mvnw --no-transfer-progress -T1C -Djava.version=${{ matrix.version }} install + run: | + echo "Install JBang via SDKMAN" + + curl -s "https://get.sdkman.io" | bash + source "/home/runner/.sdkman/bin/sdkman-init.sh" + sdk install jbang + + jbang --version + + ./mvnw --no-transfer-progress -T1C -Djava.version=${{ matrix.version }} install diff --git a/catalog/citrus-bom/pom.xml b/catalog/citrus-bom/pom.xml index ba80806dab..0e0aa4a2f4 100644 --- a/catalog/citrus-bom/pom.xml +++ b/catalog/citrus-bom/pom.xml @@ -347,6 +347,11 @@ citrus-sql 4.4.0-SNAPSHOT + + org.citrusframework + citrus-jbang-connector + 4.4.0-SNAPSHOT + diff --git a/connectors/citrus-jbang-connector/pom.xml b/connectors/citrus-jbang-connector/pom.xml new file mode 100644 index 0000000000..6fe1bf1788 --- /dev/null +++ b/connectors/citrus-jbang-connector/pom.xml @@ -0,0 +1,54 @@ + + 4.0.0 + + + org.citrusframework + citrus-connectors + 4.4.0-SNAPSHOT + ../pom.xml + + + citrus-jbang-connector + Citrus :: Connectors :: JBang + + + + org.citrusframework + citrus-base + ${project.version} + + + + org.awaitility + awaitility + + + + jakarta.xml.bind + jakarta.xml.bind-api + + + + + org.citrusframework + citrus-test-support + ${project.version} + test + + + org.citrusframework + citrus-xml + ${project.version} + test + + + org.citrusframework + citrus-yaml + ${project.version} + test + + + + diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSettings.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSettings.java new file mode 100644 index 0000000000..66d0f2dd07 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSettings.java @@ -0,0 +1,106 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Optional; + +/** + * @author Christoph Deppisch + */ +public final class JBangSettings { + + private static final String JBANG_PROPERTY_PREFIX = "citrus.jbang."; + private static final String JBANG_ENV_PREFIX = "CITRUS_JBANG_"; + + private static final String TRUST_URLS_PROPERTY = JBANG_PROPERTY_PREFIX + "trust.urls"; + private static final String TRUST_URLS_ENV = JBANG_ENV_PREFIX + "TRUST_URLS"; + + private static final String JBANG_AUTO_DOWNLOAD_PROPERTY = JBANG_PROPERTY_PREFIX + "auto.download"; + private static final String JBANG_AUTO_DOWNLOAD_ENV = JBANG_ENV_PREFIX + "AUTO_DOWNLOAD"; + private static final String JBANG_AUTO_DOWNLOAD_DEFAULT = "true"; + + private static final String JBANG_DOWNLOAD_URL_PROPERTY = JBANG_PROPERTY_PREFIX + "download.url"; + private static final String JBANG_DOWNLOAD_URL_ENV = JBANG_ENV_PREFIX + "DOWNLOAD_URL"; + private static final String JBANG_DOWNLOAD_URL_DEFAULT = "https://jbang.dev/releases/latest/download/jbang.zip"; + + private static final String WORK_DIR_PROPERTY = JBANG_PROPERTY_PREFIX + "work.dir"; + private static final String WORK_DIR_ENV = JBANG_ENV_PREFIX + "WORK_DIR"; + private static final String WORK_DIR_DEFAULT = ".citrus-jbang"; + + private static final String DUMP_PROCESS_OUTPUT_PROPERTY = JBANG_PROPERTY_PREFIX + "dump.process.output"; + private static final String DUMP_PROCESS_OUTPUT_ENV = JBANG_ENV_PREFIX + "DUMP_PROCESS_OUTPUT"; + private static final String DUMP_PROCESS_OUTPUT_DEFAULT = "false"; + + private JBangSettings() { + // prevent instantiation of utility class + } + + /** + * JBang download url. + * @return + */ + public static String getJBangDownloadUrl() { + return System.getProperty(JBANG_DOWNLOAD_URL_PROPERTY, + System.getenv(JBANG_DOWNLOAD_URL_ENV) != null ? System.getenv(JBANG_DOWNLOAD_URL_ENV) : JBANG_DOWNLOAD_URL_DEFAULT); + } + + /** + * JBang local work dir. + * @return + */ + public static Path getWorkDir() { + String workDir = Optional.ofNullable(System.getProperty(WORK_DIR_PROPERTY, System.getenv(WORK_DIR_ENV))).orElse(WORK_DIR_DEFAULT); + + Path path = Paths.get(workDir); + if (path.isAbsolute()) { + return path.toAbsolutePath(); + } else { + return Paths.get("").toAbsolutePath().resolve(workDir).toAbsolutePath(); + } + } + + /** + * JBang trust URLs. + * @return + */ + public static String[] getTrustUrls() { + return Optional.ofNullable(System.getProperty(TRUST_URLS_PROPERTY, System.getenv(TRUST_URLS_ENV))) + .map(urls -> urls.split(",")) + .orElseGet(() -> new String[]{}); + } + + /** + * When set to true JBang binary is downloaded automatically when not present on host. + * @return + */ + public static boolean isAutoDownload() { + return Boolean.parseBoolean(System.getProperty(JBANG_AUTO_DOWNLOAD_PROPERTY, + System.getenv(JBANG_AUTO_DOWNLOAD_ENV) != null ? System.getenv(JBANG_AUTO_DOWNLOAD_ENV) : JBANG_AUTO_DOWNLOAD_DEFAULT)); + } + + /** + * When set to true JBang process output will be redirected to a file in the current working directory. + * @return + */ + public static boolean isDumpProcessOutput() { + return Boolean.parseBoolean(System.getProperty(DUMP_PROCESS_OUTPUT_PROPERTY, + System.getenv(DUMP_PROCESS_OUTPUT_ENV) != null ? System.getenv(DUMP_PROCESS_OUTPUT_ENV) : DUMP_PROCESS_OUTPUT_DEFAULT)); + } + +} diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSupport.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSupport.java new file mode 100644 index 0000000000..188bf87816 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/JBangSupport.java @@ -0,0 +1,466 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.util.FileUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Support class prepares JBang executable and runs commands via spawned process using the JBang binary. + */ +public class JBangSupport { + + /** Logger */ + private static final Logger LOG = LoggerFactory.getLogger(JBangSupport.class); + + private static final boolean IS_OS_WINDOWS = System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("windows"); + + public static final int OK_EXIT_CODE = 0; + + private static Path installDir; + + private static final AtomicBoolean initialized = new AtomicBoolean(false); + + private static final Set trustUrls = new HashSet<>(); + + private final Map systemProperties = new HashMap<>(); + + private String app; + + /** + * Prevent direct instantiation. + */ + private JBangSupport() { + } + + public static JBangSupport jbang() { + if (!initialized.getAndSet(true)) { + detectJBang(); + Arrays.stream(JBangSettings.getTrustUrls()) + .forEach(JBangSupport::addTrust); + } + + return new JBangSupport(); + } + + /** + * Get JBang version. + */ + public String version() { + ProcessAndOutput p = execute(jBang("version")); + return p.getOutput(); + } + + /** + * Adds JBang trust for given URL. + */ + public JBangSupport trust(String url) { + addTrust(url); + return this; + } + + /** + * Adds system property to command line. + * @return + */ + public JBangSupport withSystemProperty(String name, String value) { + this.systemProperties.put(name, value); + return this; + } + + /** + * Adds system properties to command line. + * @return + */ + public JBangSupport withSystemProperties(Map systemProperties) { + this.systemProperties.putAll(systemProperties); + return this; + } + + public JBangSupport app(String name) { + this.app = name; + return this; + } + + /** + * Runs JBang command. + */ + public ProcessAndOutput run(String command, String... args) { + return run(command, Arrays.asList(args)); + } + + /** + * Runs JBang command and waits for the command to complete. + * Command can be a script file or an app command. + */ + public ProcessAndOutput run(String command, List args) { + return execute(jBang(systemProperties, constructAllArgs(command, args))); + } + + /** + * Runs JBang command - does not wait for the command to complete. + * Command can be a script file or an app command. + */ + public ProcessAndOutput runAsync(String command, String... args) { + return runAsync(command, Arrays.asList(args)); + } + + /** + * Runs JBang command - does not wait for the command to complete. + * Command can be a script file or an app command. + */ + public ProcessAndOutput runAsync(String command, List args) { + return executeAsync(jBang(systemProperties, constructAllArgs(command, args))); + } + + /** + * Runs JBang command - does not wait for the command to complete. + * Command can be a script file or an app command. + * Redirect the process output to given file. + */ + public ProcessAndOutput runAsync(String command, File output, String... args) { + return runAsync(command, output, Arrays.asList(args)); + } + + /** + * Runs JBang command - does not wait for the command to complete. + * Command can be a script file or an app command. + * Redirect the process output to given file. + */ + public ProcessAndOutput runAsync(String command, File output, List args) { + return executeAsync(jBang(systemProperties, constructAllArgs(command, args)), output); + } + + private List constructAllArgs(String command, List args) { + List allArgs = new ArrayList<>(); + + // JBang app name + if (app != null) { + allArgs.add(app); + } + + allArgs.add(command); + allArgs.addAll(args); + + return allArgs; + } + + private static void detectJBang() { + ProcessAndOutput result = getVersion(); + if (result.getProcess().exitValue() == OK_EXIT_CODE) { + LOG.info("Found JBang v" + result.getOutput()); + } else if (JBangSettings.isAutoDownload()){ + LOG.warn("JBang not found. Downloading ..."); + download(); + result = getVersion(); + if (result.getProcess().exitValue() == OK_EXIT_CODE) { + LOG.info("Using JBang v" + result.getOutput()); + } + } else { + throw new CitrusRuntimeException("Missing JBang installation on host - make sure to install JBang"); + } + } + + private static void download() { + String homePath = "jbang"; + + Path installPath = Paths.get(System.getProperty("user.home")).toAbsolutePath().resolve(".jbang").toAbsolutePath(); + + if (installPath.resolve(homePath).toFile().exists()) { + LOG.info("Using local JBang in " + installPath); + installDir = installPath.resolve(homePath); + return; + } + + LOG.info("Downloading JBang from " + JBangSettings.getJBangDownloadUrl() + " and installing in " + installPath); + + try { + Files.createDirectories(installPath); + + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(JBangSettings.getJBangDownloadUrl())) + .GET() + .build(); + + HttpClient client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofFileDownload(installPath, + StandardOpenOption.WRITE, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING)); + + if (response.statusCode() != 200) { + throw new CitrusRuntimeException(String.format("Failed to download JBang - response code %d", response.statusCode())); + } + + unzip(response.body(), installPath); + } catch (URISyntaxException | IOException | InterruptedException e) { + throw new CitrusRuntimeException("Failed to download JBang", e); + } + + installDir = installPath.resolve(homePath); + } + + private static ProcessAndOutput getVersion() { + return execute(jBang("version")); + } + + /** + * Execute "jbang trust add URL..." + * + * @throws CitrusRuntimeException if the exit value is different from + * 0: success + * 1: Already trusted source(s) + */ + private static void addTrust(String url) { + if (trustUrls.add(url)) { + ProcessAndOutput result = execute(jBang("trust", "add", url)); + int exitValue = result.getProcess().exitValue(); + if (exitValue != OK_EXIT_CODE && exitValue != 1) { + throw new CitrusRuntimeException("Error while trusting JBang URLs. Exit code: " + exitValue); + } + } + } + + /** + * @return JBang command with given arguments. + */ + private static List jBang(String... args) { + return jBang(List.of(args)); + } + + /** + * @return JBang command with given arguments. + */ + private static List jBang(List args) { + return jBang(Collections.emptyMap(), args); + } + + /** + * @return JBang command with given arguments. + */ + private static List jBang(Map systemProperties, List args) { + List command = new ArrayList<>(); + if (IS_OS_WINDOWS) { + command.add("cmd.exe"); + command.add("/c"); + } else { + command.add("sh"); + command.add("-c"); + } + + String jBangCommand = getJBangExecutable() + " " + getSystemPropertyArgs(systemProperties) + String.join(" ", args); + command.add(jBangCommand); + + return command; + } + + /** + * Construct command line arguments from given map of system properties. + * @param systemProperties + * @return + */ + private static String getSystemPropertyArgs(Map systemProperties) { + if (systemProperties.isEmpty()) { + return ""; + } + + return systemProperties.entrySet() + .stream() + .map(entry -> "-D%s=%s".formatted(entry.getKey(), entry.getValue())) + .collect(Collectors.joining(" ")) + " "; + } + + /** + * Execute JBang command using the process API. Waits for the process to complete and returns the process instance so + * caller is able to access the exit code and process output. + * @param command + * @return + */ + private static ProcessAndOutput execute(List command) { + try { + LOG.info("Executing JBang command: %s".formatted(String.join(" ", command))); + + Process p = new ProcessBuilder(command) + .redirectErrorStream(true) + .start(); + + String output = FileUtils.readToString(p.getInputStream(), StandardCharsets.UTF_8); + p.waitFor(); + + if (JBangSettings.isDumpProcessOutput()) { + Path workDir = JBangSettings.getWorkDir(); + FileUtils.writeToFile(output, workDir.resolve(String.format("%s-output.txt", p.pid())).toFile()); + } + + if (LOG.isDebugEnabled() && p.exitValue() != OK_EXIT_CODE) { + LOG.debug("Command failed: " + String.join(" ", command)); + LOG.debug(output); + } + + return new ProcessAndOutput(p, output); + } catch (IOException | InterruptedException e) { + throw new CitrusRuntimeException("Error while executing JBang", e); + } + } + + /** + * Execute JBang command using the process API. Waits for the process to complete and returns the process instance so + * caller is able to access the exit code and process output. + * @param command + * @return + */ + private static ProcessAndOutput executeAsync(List command) { + try { + Process p = new ProcessBuilder(command) + .redirectErrorStream(true) + .start(); + return new ProcessAndOutput(p); + } catch (IOException e) { + throw new CitrusRuntimeException("Error while executing JBang", e); + } + } + + /** + * Execute JBang command using the process API. Waits for the process to complete and returns the process instance so + * caller is able to access the exit code and process output. + * @param command + * @param outputFile + * @return + */ + private static ProcessAndOutput executeAsync(List command, File outputFile) { + try { + Process p = new ProcessBuilder(command) + .redirectErrorStream(true) + .redirectOutput(outputFile) + .start(); + + return new ProcessAndOutput(p, outputFile); + } catch (IOException e) { + throw new CitrusRuntimeException("Error while executing JBang", e); + } + } + + /** + * Gets the JBang executable name. + * @return + */ + private static String getJBangExecutable() { + if (installDir != null) { + if (IS_OS_WINDOWS) { + return installDir.resolve("bin/jbang.cmd").toString(); + } else { + return installDir.resolve("bin/jbang").toString(); + } + } else { + if (IS_OS_WINDOWS) { + return "jbang.cmd"; + } else { + return "jbang"; + } + } + } + + /** + * Extract JBang download.zip to install directory. + * @param downloadZip + * @param installPath + * @throws IOException + */ + private static void unzip(Path downloadZip, Path installPath) throws IOException { + ZipInputStream zis = new ZipInputStream(new FileInputStream(downloadZip.toFile())); + ZipEntry zipEntry = zis.getNextEntry(); + while (zipEntry != null) { + Path filePath = newFile(installPath, zipEntry); + File newFile = filePath.toFile(); + if (zipEntry.isDirectory()) { + if (!newFile.isDirectory() && !newFile.mkdirs()) { + throw new IOException("Failed to create directory " + newFile); + } + } else { + // fix for Windows-created archives + File parent = newFile.getParentFile(); + if (!parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Failed to create directory " + parent); + } + + // write file content + Files.copy(zis, filePath, StandardCopyOption.REPLACE_EXISTING); + + if ("jbang".equals(filePath.getFileName().toString())) { + Files.setPosixFilePermissions(filePath, PosixFilePermissions.fromString("rwxr--r--")); + } + } + zipEntry = zis.getNextEntry(); + } + + zis.closeEntry(); + zis.close(); + } + + /** + * Guards against writing files to the file system outside the target folder also known as Zip slip vulnerability. + * @param destinationDir + * @param zipEntry + * @return + * @throws IOException + */ + private static Path newFile(Path destinationDir, ZipEntry zipEntry) throws IOException { + Path destFile = destinationDir.resolve(zipEntry.getName()); + + String destDirPath = destinationDir.toFile().getCanonicalPath(); + String destFilePath = destFile.toFile().getCanonicalPath(); + + if (!destFilePath.startsWith(destDirPath + File.separator)) { + throw new IOException("Entry is outside of the target dir: " + zipEntry.getName()); + } + + return destFile; + } +} diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/ProcessAndOutput.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/ProcessAndOutput.java new file mode 100644 index 0000000000..636463930f --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/ProcessAndOutput.java @@ -0,0 +1,168 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.concurrent.TimeUnit; + +import org.awaitility.core.ConditionTimeoutException; +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.awaitility.Awaitility.await; + +/** + * Process wrapper also holds the output that has been produced by the completed process. + */ +public class ProcessAndOutput { + + /** Logger */ + private static final Logger LOG = LoggerFactory.getLogger(ProcessAndOutput.class); + + private final Process process; + private String output; + + private BufferedReader reader; + + ProcessAndOutput(Process process) { + this(process, ""); + } + + ProcessAndOutput(Process process, String output) { + this.process = process; + this.output = output; + } + + ProcessAndOutput(Process process, File outputFile) { + this.process = process; + try { + this.reader = new BufferedReader(new InputStreamReader(new FileInputStream(outputFile))); + } catch (FileNotFoundException e) { + throw new CitrusRuntimeException(String.format("Failed to access process output file %s", outputFile.getName()), e); + } + } + + public Process getProcess() { + return process; + } + + public String getOutput() { + if (process.isAlive()) { + readChunk(); + } else if (reader != null) { + readAllAndClose(); + } + + return output; + } + + /** + * Reads process output until EOF and closes the stream reader. + */ + private void readAllAndClose() { + String line; + StringBuilder builder = new StringBuilder(); + try { + while ((line = reader.readLine()) != null) { + builder.append(line).append(System.lineSeparator()); + } + + if (!builder.isEmpty()) { + output += builder; + } + } catch (IOException e) { + throw new CitrusRuntimeException("Failed to get JBang process output", e); + } finally { + try { + reader.close(); + } catch (IOException e) { + LOG.debug("Failed to close JBang process output reader", e); + } finally { + reader = null; + } + } + } + + /** + * Reads a chunk of process output. Either reads maximum amount of lines or returns when reader is not ready (e.g. + * no process output available). Chunk is added to the cached process output. + */ + private void readChunk() { + if (reader == null) { + reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + } + + String line; + int read = 1; + int maxRead = 100; + StringBuilder builder = new StringBuilder(); + try { + while (read <= maxRead && reader.ready() && (line = reader.readLine()) != null) { + builder.append(line).append(System.lineSeparator()); + read++; + } + } catch (IOException e) { + throw new CitrusRuntimeException("Failed to get JBang process output", e); + } + + if (!builder.isEmpty()) { + if (output == null) { + output = builder.toString(); + } else { + output += builder.toString().stripTrailing(); + } + } + } + + /** + * Get the process id of first descendant or the parent process itself in case there is no descendant process. + * On Linux the shell command represents the parent process and the JBang command as descendant process. + * Typically, we need the JBang command process id. + * @return + */ + public Long getProcessId(String app) { + try { + if (isUnix()) { + // wait for descendant process to be available + await().atMost(5000L, TimeUnit.MILLISECONDS) + .until(() -> process.descendants().findAny().isPresent()); + return process.descendants() + .filter(p -> p.info().commandLine().orElse("").contains(app)) + .findFirst() + .map(ProcessHandle::pid) + .orElse(process.pid()); + } + + return process.pid(); + } catch (ConditionTimeoutException | UnsupportedOperationException | SecurityException e) { + // not able or not allowed to manage descendant process snapshot + // return parent process id as a fallback + return process.pid(); + } + } + + private static boolean isUnix() { + String os = System.getProperty("os.name").toLowerCase(); + return os.contains("nix") || os.contains("nux") || os.contains("aix"); + } +} diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/actions/JBangAction.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/actions/JBangAction.java new file mode 100644 index 0000000000..59d754d9b4 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/actions/JBangAction.java @@ -0,0 +1,272 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.actions; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.citrusframework.AbstractTestActionBuilder; +import org.citrusframework.actions.AbstractTestAction; +import org.citrusframework.context.TestContext; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.jbang.JBangSupport; +import org.citrusframework.jbang.ProcessAndOutput; +import org.citrusframework.message.DefaultMessage; +import org.citrusframework.spi.Resource; +import org.citrusframework.spi.Resources; +import org.citrusframework.util.FileUtils; +import org.citrusframework.validation.ValidationProcessor; +import org.citrusframework.validation.ValidationUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Action runs scripts with JBang. Spawns a new process with the JBang executable. + * Arguments and system properties are provided and the process exit code and output may be validated. + */ +public class JBangAction extends AbstractTestAction { + + private final String app; + private final String scriptOrFile; + private final List args; + private final Map systemProperties; + private final int[] exitCodes; + private final String pidVar; + private final String outputVar; + private final String verifyOutput; + private final ValidationProcessor validationProcessor; + private final boolean printOutput; + + /** Logger */ + private static final Logger logger = LoggerFactory.getLogger(JBangAction.class); + + public JBangAction(Builder builder) { + super("jbang", builder); + + this.app = builder.app; + this.scriptOrFile = builder.scriptOrFile; + this.args = builder.args; + this.systemProperties = builder.systemProperties; + this.exitCodes = builder.exitCodes; + this.pidVar = builder.pidVar; + this.outputVar = builder.outputVar; + this.verifyOutput = builder.verifyOutput; + this.validationProcessor = builder.validationProcessor; + this.printOutput = builder.printOutput; + } + + @Override + public void doExecute(TestContext context) { + String scriptName = FileUtils.getFileName(context.replaceDynamicContentInString(scriptOrFile)); + logger.info("Running JBang script '%s'".formatted(scriptName)); + + ProcessAndOutput result = JBangSupport.jbang() + .app(app) + .withSystemProperties(systemProperties) + .run(context.replaceDynamicContentInString(scriptOrFile), context.resolveDynamicValuesInList(args)); + + if (printOutput) { + logger.info("JBang script '%s' output:".formatted(scriptName)); + logger.info(result.getOutput()); + } + + if (pidVar != null) { + context.setVariable(pidVar, result.getProcessId(Objects.requireNonNullElse(app, scriptName))); + } + + int exitValue = result.getProcess().exitValue(); + if (Arrays.stream(exitCodes).noneMatch(exit -> exit == exitValue)) { + if (exitCodes.length == 1) { + throw new ValidationException(("Error while running JBang script or file. " + + "Expected exit code %s, but was %d").formatted(exitCodes[0], exitValue)); + } else { + throw new ValidationException(("Error while running JBang script or file. " + + "Expected one of exit codes %s, but was %d").formatted(Arrays.toString(exitCodes), exitValue)); + } + } + + if (validationProcessor != null) { + validationProcessor.validate(new DefaultMessage(result.getOutput().trim()) + .setHeader("pid", result.getProcessId(Objects.requireNonNullElse(app, scriptName))) + .setHeader("exitCode", result.getProcess().exitValue()), context); + } + + if (verifyOutput != null) { + ValidationUtils.validateValues(result.getOutput().trim(), verifyOutput, "jbang-output", context); + } + + if (outputVar != null) { + context.setVariable(context.replaceDynamicContentInString(outputVar), result.getOutput().trim()); + } + + logger.info("JBang script '%s' finished successfully".formatted(scriptName)); + } + + /** + * Action builder. + */ + public static final class Builder extends AbstractTestActionBuilder { + + private String app; + private String scriptOrFile; + private final List args = new ArrayList<>(); + private final Map systemProperties = new HashMap<>(); + private int[] exitCodes = new int[] { JBangSupport.OK_EXIT_CODE, 1 }; + private String pidVar; + private String outputVar; + private String verifyOutput; + private ValidationProcessor validationProcessor; + private boolean printOutput = true; + + /** + * Fluent API action building entry method used in Java DSL. + * @return + */ + public static Builder jbang() { + return new Builder(); + } + + public Builder app(String name) { + this.app = name; + return this; + } + + public Builder command(String command) { + this.scriptOrFile = command; + return this; + } + + public Builder script(String script) { + this.scriptOrFile = script; + return this; + } + + public Builder file(String path) { + this.scriptOrFile = Resources.create(path).getFile().getAbsolutePath(); + return this; + } + + public Builder file(Resource resource) { + this.scriptOrFile = resource.getFile().getAbsolutePath(); + return this; + } + + public Builder arg(String name, String value) { + this.args.add("%s=%s".formatted(name, value)); + return this; + } + + public Builder arg(String value) { + this.args.add(value); + return this; + } + + public Builder args(String... args) { + this.args.addAll(List.of(args)); + return this; + } + + public Builder systemProperty(String name, String value) { + this.systemProperties.put(name, value); + return this; + } + + public Builder exitCode(int code) { + this.exitCodes = new int[] {code}; + return this; + } + + public Builder exitCodes(int... codes) { + this.exitCodes = codes; + return this; + } + + public Builder printOutput(boolean enabled) { + this.printOutput = enabled; + return this; + } + + public Builder verifyOutput(String expected) { + this.verifyOutput = expected; + return this; + } + + public Builder verifyOutput(ValidationProcessor validationProcessor) { + this.validationProcessor = validationProcessor; + return this; + } + + public Builder savePid(String variable) { + this.pidVar = variable; + return this; + } + + public Builder saveOutput(String variable) { + this.outputVar = variable; + return this; + } + + @Override + public JBangAction build() { + return new JBangAction(this); + } + } + + public int[] getExitCodes() { + return exitCodes; + } + + public List getArgs() { + return args; + } + + public String getApp() { + return app; + } + + public String getScriptOrFile() { + return scriptOrFile; + } + + public String getOutputVar() { + return outputVar; + } + + public String getPidVar() { + return pidVar; + } + + public String getVerifyOutput() { + return verifyOutput; + } + + public boolean isPrintOutput() { + return printOutput; + } + + public Map getSystemProperties() { + return systemProperties; + } + + public ValidationProcessor getValidationProcessor() { + return validationProcessor; + } +} diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/JBang.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/JBang.java new file mode 100644 index 0000000000..621ce91d56 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/JBang.java @@ -0,0 +1,220 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.xml; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlAttribute; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; +import jakarta.xml.bind.annotation.XmlType; +import org.citrusframework.TestActionBuilder; +import org.citrusframework.jbang.actions.JBangAction; + +@XmlRootElement(name = "jbang") +public class JBang implements TestActionBuilder { + + private final JBangAction.Builder builder = new JBangAction.Builder(); + + @XmlElement + public JBang setDescription(String value) { + builder.description(value); + return this; + } + + @XmlAttribute + public JBang setApp(String name) { + builder.app(name); + return this; + } + + @XmlAttribute + public JBang setCommand(String command) { + builder.command(command); + return this; + } + + @XmlAttribute + public JBang setFile(String path) { + builder.file(path); + return this; + } + + @XmlAttribute + public JBang setArgs(String args) { + builder.args(args.split(",")); + return this; + } + + @XmlAttribute(name = "exit-code") + public JBang setExitCode(String codes) { + builder.exitCodes(Arrays.stream(codes.split(",")) + .map(String::trim) + .map(Integer::parseInt) + .mapToInt(Integer::intValue).toArray()); + return this; + } + + @XmlAttribute(name = "print-output") + public JBang setPrintOutput(boolean enabled) { + builder.printOutput(enabled); + return this; + } + + @XmlElement + public JBang setOutput(String expected) { + builder.verifyOutput(expected); + return this; + } + + @XmlAttribute(name = "save-pid") + public JBang setSavePid(String variable) { + builder.savePid(variable); + return this; + } + + @XmlAttribute(name = "save-output") + public JBang setSaveOutput(String variable) { + builder.saveOutput(variable); + return this; + } + + @XmlElement(name = "args") + public JBang setArguments(Arguments arguments) { + for (Arguments.Argument argument : arguments.getArguments()) { + if (argument.getName() != null) { + builder.arg(argument.getName(), argument.getValue()); + } else { + builder.arg(argument.getValue()); + } + } + + return this; + } + + @XmlElement(name = "system-properties") + public JBang setSystemProperties(SystemProperties systemProperties) { + for (SystemProperties.SystemProperty sysProp : systemProperties.getSystemProperties()) { + builder.systemProperty(sysProp.getName(), sysProp.getValue()); + } + + return this; + } + + @Override + public JBangAction build() { + return builder.build(); + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "", propOrder = { + "arguments" + }) + public static class Arguments { + + @XmlElement(name = "arg") + private List arguments; + + public List getArguments() { + if (arguments == null) { + arguments = new ArrayList<>(); + } + return this.arguments; + } + + public void setArguments(List arguments) { + this.arguments = arguments; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "") + public static class Argument { + @XmlAttribute + private String name; + + @XmlAttribute(required = true) + private String value; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "", propOrder = { + "systemProperties" + }) + public static class SystemProperties { + + @XmlElement(name = "system-property") + private List systemProperties; + + public List getSystemProperties() { + if (systemProperties == null) { + systemProperties = new ArrayList<>(); + } + return this.systemProperties; + } + + public void setSystemProperties(List systemProperties) { + this.systemProperties = systemProperties; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "") + public static class SystemProperty { + @XmlAttribute(required = true) + private String name; + + @XmlAttribute(required = true) + private String value; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + } +} diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/ObjectFactory.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/ObjectFactory.java new file mode 100644 index 0000000000..bdd5dd43ed --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/ObjectFactory.java @@ -0,0 +1,52 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.xml; + +import jakarta.xml.bind.annotation.XmlRegistry; + +/** + * This object contains factory methods for each + * Java content interface and Java element interface + * generated in the org.citrusframework.ftp.model package. + *

An ObjectFactory allows you to programatically + * construct new instances of the Java representation + * for XML content. The Java representation of XML + * content can consist of schema derived interfaces + * and classes representing the binding of schema + * type definitions, element declarations and model + * groups. Factory methods for each of these are + * provided in this class. + * + */ +@XmlRegistry +public class ObjectFactory { + + /** + * Create a new ObjectFactory that can be used to create new instances of schema derived classes for package: org.citrusframework.xml.actions + * + */ + public ObjectFactory() { + } + + /** + * Create an instance of {@link JBang } + * + */ + public JBang createJBang() { + return new JBang(); + } +} diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/package-info.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/package-info.java new file mode 100644 index 0000000000..c5e101eb53 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/xml/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@jakarta.xml.bind.annotation.XmlSchema(namespace = "http://citrusframework.org/schema/xml/testcase", elementFormDefault = jakarta.xml.bind.annotation.XmlNsForm.QUALIFIED) +package org.citrusframework.jbang.xml; diff --git a/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/yaml/JBang.java b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/yaml/JBang.java new file mode 100644 index 0000000000..f4674f12f9 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/java/org/citrusframework/jbang/yaml/JBang.java @@ -0,0 +1,154 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.yaml; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.citrusframework.TestActionBuilder; +import org.citrusframework.jbang.actions.JBangAction; + +public class JBang implements TestActionBuilder { + + private final JBangAction.Builder builder = new JBangAction.Builder(); + + private List arguments; + private List systemProperties; + + public void setDescription(String value) { + builder.description(value); + } + + public void setApp(String name) { + builder.app(name); + } + + public void setCommand(String command) { + builder.command(command); + } + + public void setFile(String path) { + builder.file(path); + } + + public void setExitCode(String codes) { + builder.exitCodes(Arrays.stream(codes.split(",")) + .map(String::trim) + .map(Integer::parseInt) + .mapToInt(Integer::intValue).toArray()); + } + + public void setPrintOutput(boolean enabled) { + builder.printOutput(enabled); + } + + public void setOutput(String expected) { + builder.verifyOutput(expected); + } + + public void setSavePid(String variable) { + builder.savePid(variable); + } + + public void setSaveOutput(String variable) { + builder.saveOutput(variable); + } + + public List getArguments() { + if (arguments == null) { + arguments = new ArrayList<>(); + } + return this.arguments; + } + + public void setArgs(List arguments) { + this.arguments = arguments; + } + + public List getSystemProperties() { + if (systemProperties == null) { + systemProperties = new ArrayList<>(); + } + return this.systemProperties; + } + + public void setSystemProperties(List systemProperties) { + this.systemProperties = systemProperties; + } + + @Override + public JBangAction build() { + for (Argument argument : getArguments()) { + if (argument.getName() != null) { + builder.arg(argument.getName(), argument.getValue()); + } else { + builder.arg(argument.getValue()); + } + } + + for (SystemProperty sysProp : getSystemProperties()) { + builder.systemProperty(sysProp.getName(), sysProp.getValue()); + } + + return builder.build(); + } + + public static class Argument { + private String name; + + private String value; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } + + public static class SystemProperty { + private String name; + + private String value; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + } +} diff --git a/connectors/citrus-jbang-connector/src/main/resources/META-INF/LICENSE.txt b/connectors/citrus-jbang-connector/src/main/resources/META-INF/LICENSE.txt new file mode 100644 index 0000000000..0234973237 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/resources/META-INF/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright the original author or authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/connectors/citrus-jbang-connector/src/main/resources/META-INF/NOTICE.txt b/connectors/citrus-jbang-connector/src/main/resources/META-INF/NOTICE.txt new file mode 100644 index 0000000000..ad3886b5ab --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/resources/META-INF/NOTICE.txt @@ -0,0 +1,32 @@ + ======================================================================== + == NOTICE file corresponding to section 4 d of the Apache License, == + == Version 2.0, in this case for the Citrus distribution. == + ======================================================================== + + This product includes software developed by the Citrus + project (https://citrusframework.org). + + The end-user documentation included with a redistribution, if any, + must include the following acknowledgement: + + "This product includes software developed by the Citrus + Project (https://citrusframework.org)." + + Alternatively, this acknowledgement may appear in the software itself, + if and wherever such third-party acknowledgements normally appear. + + The names "Citrus" and "Citrus Framework" must not be used to endorse or + promote products derived from this software without prior written permission. + For written permission, please contact user@citrusframework.org. + + Copyright (C) 2006-2023 the original author or authors. + + Citrus is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + FOR A PARTICULAR PURPOSE. + + You should have received a copy of the Apache License Version 2.0 + along with Citrus. If not, see . + + dev@citrusframework.org + https://citrusframework.org diff --git a/connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/xml/builder/jbang b/connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/xml/builder/jbang new file mode 100644 index 0000000000..598544db91 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/xml/builder/jbang @@ -0,0 +1,2 @@ +type=org.citrusframework.jbang.xml.JBang +ns=http://citrusframework.org/schema/xml/testcase diff --git a/connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/yaml/builder/jbang b/connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/yaml/builder/jbang new file mode 100644 index 0000000000..06051ab0c9 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/main/resources/META-INF/citrus/yaml/builder/jbang @@ -0,0 +1 @@ +type=org.citrusframework.jbang.yaml.JBang diff --git a/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/UnitTestSupport.java b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/UnitTestSupport.java new file mode 100644 index 0000000000..34d5f844d1 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/UnitTestSupport.java @@ -0,0 +1,40 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang; + +import org.citrusframework.context.TestContext; +import org.citrusframework.context.TestContextFactory; +import org.testng.annotations.BeforeMethod; + +public abstract class UnitTestSupport { + + protected TestContextFactory testContextFactory; + protected TestContext context; + + /** + * Setup test execution. + */ + @BeforeMethod + public void prepareTest() { + testContextFactory = createTestContextFactory(); + context = testContextFactory.getObject(); + } + + protected TestContextFactory createTestContextFactory() { + return TestContextFactory.newInstance(); + } +} diff --git a/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/actions/JBangActionTest.java b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/actions/JBangActionTest.java new file mode 100644 index 0000000000..1a80074d7c --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/actions/JBangActionTest.java @@ -0,0 +1,117 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.actions; + +import org.citrusframework.exceptions.CitrusRuntimeException; +import org.citrusframework.exceptions.ValidationException; +import org.citrusframework.jbang.UnitTestSupport; +import org.citrusframework.spi.Resource; +import org.citrusframework.spi.Resources; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class JBangActionTest extends UnitTestSupport { + + private final Resource helloScript = Resources.fromClasspath("org/citrusframework/jbang/hello.java"); + + @Test + public void testScriptOrFile() { + JBangAction jbang = new JBangAction.Builder() + .file(helloScript) + .build(); + jbang.execute(context); + } + + @Test + public void testVerifyOutput() { + JBangAction jbang = new JBangAction.Builder() + .file(helloScript) + .arg("Citrus") + .verifyOutput("Hello Citrus") + .build(); + jbang.execute(context); + } + + @Test + public void testValidationProcessor() { + JBangAction jbang = new JBangAction.Builder() + .file(helloScript) + .arg("Citrus") + .verifyOutput((message, context) -> { + Assert.assertEquals(message.getPayload(String.class), "Hello Citrus"); + Assert.assertEquals(message.getHeader("exitCode"), 0); + Assert.assertTrue(message.getHeaders().containsKey("pid")); + Assert.assertFalse(message.getHeader("pid").toString().isEmpty()); + }) + .build(); + jbang.execute(context); + } + + @Test + public void testJBangCommand() { + JBangAction jbang = new JBangAction.Builder().command("version").build(); + jbang.execute(context); + } + + @Test + public void testJBangCommandSavePid() { + JBangAction jbang = new JBangAction.Builder().command("version") + .savePid("versionPid") + .build(); + jbang.execute(context); + + Assert.assertTrue(context.getVariables().containsKey("versionPid")); + } + + @Test + public void testJBangCommandSaveOutput() { + JBangAction jbang = new JBangAction.Builder().command("version") + .saveOutput("out") + .build(); + jbang.execute(context); + + Assert.assertTrue(context.getVariables().containsKey("out")); + } + + @Test + public void testJBangCommandWithVariables() { + JBangAction jbang = new JBangAction.Builder().command("${command}").build(); + context.setVariable("command", "version"); + + jbang.execute(context); + } + + @Test(expectedExceptions = {ValidationException.class}, + expectedExceptionsMessageRegExp = "Error while running JBang script or file. Expected exit code -1, but was 0") + public void testJBangCommandValidateExitCode() { + JBangAction jbang = new JBangAction.Builder().command("version").exitCodes(-1).build(); + jbang.execute(context); + } + + @Test(expectedExceptions = {CitrusRuntimeException.class}) + public void testUnknownApp() { + JBangAction jbang = new JBangAction.Builder().app("unknown").command("version").build(); + jbang.execute(context); + } + + @Test(expectedExceptions = {CitrusRuntimeException.class}) + public void testUnknownCommand() { + JBangAction jbang = new JBangAction.Builder().command("unknown").build(); + jbang.execute(context); + } + +} diff --git a/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/AbstractXmlActionTest.java b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/AbstractXmlActionTest.java new file mode 100644 index 0000000000..40ae29206c --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/AbstractXmlActionTest.java @@ -0,0 +1,76 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.xml; + +import org.citrusframework.Citrus; +import org.citrusframework.CitrusContext; +import org.citrusframework.CitrusInstanceManager; +import org.citrusframework.DefaultTestCaseRunner; +import org.citrusframework.annotations.CitrusAnnotations; +import org.citrusframework.context.StaticTestContextFactory; +import org.citrusframework.context.TestContext; +import org.citrusframework.testng.AbstractTestNGUnitTest; +import org.citrusframework.xml.XmlTestLoader; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.BeforeClass; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +public class AbstractXmlActionTest extends AbstractTestNGUnitTest { + + protected Citrus citrus; + + @Mock + protected CitrusContext citrusContext; + + @BeforeClass + public void setupMocks() { + MockitoAnnotations.openMocks(this); + citrus = CitrusInstanceManager.newInstance(() -> citrusContext); + } + + @Override + protected TestContext createTestContext() { + TestContext context = super.createTestContext(); + when(citrusContext.getReferenceResolver()).thenReturn(context.getReferenceResolver()); + when(citrusContext.getMessageValidatorRegistry()).thenReturn(context.getMessageValidatorRegistry()); + when(citrusContext.getTestContextFactory()).thenReturn(new StaticTestContextFactory(context)); + doAnswer(invocationOnMock -> { + CitrusAnnotations.parseConfiguration(invocationOnMock.getArgument(0, Object.class), citrusContext); + return null; + }).when(citrusContext).parseConfiguration((Object) any()); + doAnswer(invocationOnMock-> { + context.getReferenceResolver().bind(invocationOnMock.getArgument(0), invocationOnMock.getArgument(1)); + return null; + }).when(citrusContext).addComponent(anyString(), any()); + CitrusAnnotations.injectAll(this, citrus, context); + return context; + } + + protected XmlTestLoader createTestLoader(String sourcePath) { + XmlTestLoader testLoader = new XmlTestLoader(this.getClass(), "Test", this.getClass().getPackageName()); + CitrusAnnotations.injectAll(testLoader, citrus, context); + CitrusAnnotations.injectTestRunner(testLoader, new DefaultTestCaseRunner(context)); + testLoader.setSource(sourcePath); + + return testLoader; + } +} diff --git a/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/JBangTest.java b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/JBangTest.java new file mode 100644 index 0000000000..07cc310078 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/xml/JBangTest.java @@ -0,0 +1,75 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.xml; + +import org.citrusframework.TestCase; +import org.citrusframework.TestCaseMetaInfo; +import org.citrusframework.jbang.actions.JBangAction; +import org.citrusframework.xml.XmlTestLoader; +import org.citrusframework.xml.actions.XmlTestActionBuilder; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class JBangTest extends AbstractXmlActionTest { + + @Test + public void shouldLoadJBangActions() { + XmlTestLoader testLoader = createTestLoader("classpath:org/citrusframework/jbang/xml/jbang-test.xml"); + + testLoader.load(); + TestCase result = testLoader.getTestCase(); + Assert.assertEquals(result.getName(), "JBangTest"); + Assert.assertEquals(result.getMetaInfo().getAuthor(), "Christoph"); + Assert.assertEquals(result.getMetaInfo().getStatus(), TestCaseMetaInfo.Status.FINAL); + Assert.assertEquals(result.getActionCount(), 3L); + Assert.assertEquals(result.getTestAction(0).getClass(), JBangAction.class); + + int actionIndex = 0; + + JBangAction action = (JBangAction) result.getTestAction(actionIndex++); + Assert.assertEquals(action.getScriptOrFile(), "version"); + Assert.assertEquals(action.getArgs().size(), 1L); + Assert.assertEquals(action.getArgs().get(0), "--verbose"); + Assert.assertTrue(action.isPrintOutput()); + + action = (JBangAction) result.getTestAction(actionIndex++); + Assert.assertTrue(action.getScriptOrFile().endsWith("hello.java")); + Assert.assertEquals(action.getArgs().size(), 1L); + Assert.assertEquals(action.getArgs().get(0), "Citrus"); + Assert.assertEquals(action.getOutputVar(), "out"); + Assert.assertEquals(action.getPidVar(), "pid"); + Assert.assertTrue(action.isPrintOutput()); + + action = (JBangAction) result.getTestAction(actionIndex); + Assert.assertTrue(action.getScriptOrFile().endsWith("hello.java")); + Assert.assertEquals(action.getArgs().size(), 1L); + Assert.assertEquals(action.getArgs().get(0), "Citrus"); + Assert.assertTrue(action.getVerifyOutput().endsWith("Hello Citrus")); + Assert.assertEquals(action.getExitCodes(), new int[]{0}); + Assert.assertFalse(action.isPrintOutput()); + + Assert.assertTrue(context.getVariables().containsKey("pid")); + Assert.assertNotNull(context.getVariable("out")); + Assert.assertTrue(context.getVariable("out").endsWith("Hello Citrus")); + } + + @Test + public void shouldLookupTestActionBuilder() { + Assert.assertTrue(XmlTestActionBuilder.lookup("jbang").isPresent()); + Assert.assertEquals(XmlTestActionBuilder.lookup("jbang").get().getClass(), JBang.class); + } +} diff --git a/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/AbstractYamlActionTest.java b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/AbstractYamlActionTest.java new file mode 100644 index 0000000000..c39c416494 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/AbstractYamlActionTest.java @@ -0,0 +1,89 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.yaml; + +import org.citrusframework.Citrus; +import org.citrusframework.CitrusContext; +import org.citrusframework.CitrusInstanceManager; +import org.citrusframework.DefaultTestCaseRunner; +import org.citrusframework.TestAction; +import org.citrusframework.TestActionBuilder; +import org.citrusframework.annotations.CitrusAnnotations; +import org.citrusframework.context.StaticTestContextFactory; +import org.citrusframework.context.TestContext; +import org.citrusframework.testng.AbstractTestNGUnitTest; +import org.citrusframework.yaml.YamlTestLoader; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.testng.annotations.BeforeClass; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +public class AbstractYamlActionTest extends AbstractTestNGUnitTest { + + protected Citrus citrus; + + @Mock + protected CitrusContext citrusContext; + + @BeforeClass + public void setupMocks() { + MockitoAnnotations.openMocks(this); + citrus = CitrusInstanceManager.newInstance(() -> citrusContext); + } + + @Override + protected TestContext createTestContext() { + TestContext context = super.createTestContext(); + when(citrusContext.getReferenceResolver()).thenReturn(context.getReferenceResolver()); + when(citrusContext.getMessageValidatorRegistry()).thenReturn(context.getMessageValidatorRegistry()); + when(citrusContext.getTestContextFactory()).thenReturn(new StaticTestContextFactory(context)); + doAnswer(invocationOnMock -> { + CitrusAnnotations.parseConfiguration(invocationOnMock.getArgument(0, Object.class), citrusContext); + return null; + }).when(citrusContext).parseConfiguration((Object) any()); + doAnswer(invocationOnMock-> { + context.getReferenceResolver().bind(invocationOnMock.getArgument(0), invocationOnMock.getArgument(1)); + return null; + }).when(citrusContext).addComponent(anyString(), any()); + CitrusAnnotations.injectAll(this, citrus, context); + return context; + } + + protected YamlTestLoader createTestLoader(String sourcePath) { + YamlTestLoader testLoader = new YamlTestLoader(this.getClass(), "Test", this.getClass().getPackageName()); + CitrusAnnotations.injectAll(testLoader, citrus, context); + CitrusAnnotations.injectTestRunner(testLoader, new NoopTestCaseRunner(context)); + testLoader.setSource(sourcePath); + + return testLoader; + } + + protected static class NoopTestCaseRunner extends DefaultTestCaseRunner { + public NoopTestCaseRunner(TestContext context) { + super(context); + } + + @Override + public T run(TestActionBuilder builder) { + return builder.build(); + } + } +} diff --git a/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/JBangTest.java b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/JBangTest.java new file mode 100644 index 0000000000..596e61dc2b --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/java/org/citrusframework/jbang/yaml/JBangTest.java @@ -0,0 +1,75 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.citrusframework.jbang.yaml; + +import org.citrusframework.TestCase; +import org.citrusframework.TestCaseMetaInfo; +import org.citrusframework.jbang.actions.JBangAction; +import org.citrusframework.yaml.YamlTestLoader; +import org.citrusframework.yaml.actions.YamlTestActionBuilder; +import org.testng.Assert; +import org.testng.annotations.Test; + +public class JBangTest extends AbstractYamlActionTest { + + @Test + public void shouldLoadJBangActions() { + YamlTestLoader testLoader = createTestLoader("classpath:org/citrusframework/jbang/yaml/jbang-test.yaml"); + + testLoader.load(); + TestCase result = testLoader.getTestCase(); + Assert.assertEquals(result.getName(), "JBangTest"); + Assert.assertEquals(result.getMetaInfo().getAuthor(), "Christoph"); + Assert.assertEquals(result.getMetaInfo().getStatus(), TestCaseMetaInfo.Status.FINAL); + Assert.assertEquals(result.getActionCount(), 3L); + Assert.assertEquals(result.getTestAction(0).getClass(), JBangAction.class); + + int actionIndex = 0; + + JBangAction action = (JBangAction) result.getTestAction(actionIndex++); + Assert.assertEquals(action.getScriptOrFile(), "version"); + Assert.assertEquals(action.getArgs().size(), 1L); + Assert.assertEquals(action.getArgs().get(0), "--verbose"); + Assert.assertTrue(action.isPrintOutput()); + + action = (JBangAction) result.getTestAction(actionIndex++); + Assert.assertTrue(action.getScriptOrFile().endsWith("hello.java")); + Assert.assertEquals(action.getArgs().size(), 1L); + Assert.assertEquals(action.getArgs().get(0), "Citrus"); + Assert.assertEquals(action.getOutputVar(), "out"); + Assert.assertEquals(action.getPidVar(), "pid"); + Assert.assertTrue(action.isPrintOutput()); + + action = (JBangAction) result.getTestAction(actionIndex); + Assert.assertTrue(action.getScriptOrFile().endsWith("hello.java")); + Assert.assertEquals(action.getArgs().size(), 1L); + Assert.assertEquals(action.getArgs().get(0), "Citrus"); + Assert.assertTrue(action.getVerifyOutput().endsWith("Hello Citrus")); + Assert.assertEquals(action.getExitCodes(), new int[]{0}); + Assert.assertFalse(action.isPrintOutput()); + + Assert.assertTrue(context.getVariables().containsKey("pid")); + Assert.assertNotNull(context.getVariable("out")); + Assert.assertTrue(context.getVariable("out").endsWith("Hello Citrus")); + } + + @Test + public void shouldLookupTestActionBuilder() { + Assert.assertTrue(YamlTestActionBuilder.lookup("jbang").isPresent()); + Assert.assertEquals(YamlTestActionBuilder.lookup("jbang").get().getClass(), JBang.class); + } +} diff --git a/connectors/citrus-jbang-connector/src/test/resources/log4j2-test.xml b/connectors/citrus-jbang-connector/src/test/resources/log4j2-test.xml new file mode 100644 index 0000000000..73d4fb3953 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/resources/log4j2-test.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/context/citrus-unit-context.xml b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/context/citrus-unit-context.xml new file mode 100644 index 0000000000..6ebc3ce93d --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/context/citrus-unit-context.xml @@ -0,0 +1,7 @@ + + + + diff --git a/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/hello.java b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/hello.java new file mode 100644 index 0000000000..9a9ae3fb80 --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/hello.java @@ -0,0 +1,22 @@ +/* + * Copyright the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class hello { + + public static void main(String... args) throws Exception { + System.out.println("Hello " + ((args.length>0)?args[0]:"jbang")); + } +} diff --git a/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/xml/jbang-test.xml b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/xml/jbang-test.xml new file mode 100644 index 0000000000..cc5d96c16a --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/xml/jbang-test.xml @@ -0,0 +1,36 @@ + + + + Sample test in XML + + + + + + + + + + + + + Hello Citrus + + + diff --git a/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/yaml/jbang-test.yaml b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/yaml/jbang-test.yaml new file mode 100644 index 0000000000..6cd8bbeb5e --- /dev/null +++ b/connectors/citrus-jbang-connector/src/test/resources/org/citrusframework/jbang/yaml/jbang-test.yaml @@ -0,0 +1,25 @@ +name: "JBangTest" +author: "Christoph" +status: "FINAL" +description: Sample test in XML +actions: + - jbang: + command: "version" + args: + - value: "--verbose" + - jbang: + file: "classpath:org/citrusframework/jbang/hello.java" + args: + - value: "Citrus" + saveOutput: "out" + savePid: "pid" + - jbang: + file: "classpath:org/citrusframework/jbang/hello.java" + printOutput: false + exitCode: "0" + systemProperties: + - name: "foo" + value: "bar" + args: + - value: "Citrus" + output: Hello Citrus diff --git a/connectors/pom.xml b/connectors/pom.xml index 18a1df3ad4..189b6b3ddc 100644 --- a/connectors/pom.xml +++ b/connectors/pom.xml @@ -20,6 +20,7 @@ citrus-kubernetes citrus-selenium citrus-sql + citrus-jbang-connector diff --git a/core/citrus-base/src/main/java/org/citrusframework/actions/EchoAction.java b/core/citrus-base/src/main/java/org/citrusframework/actions/EchoAction.java index 783b618cf4..c9cf2dbc9b 100644 --- a/core/citrus-base/src/main/java/org/citrusframework/actions/EchoAction.java +++ b/core/citrus-base/src/main/java/org/citrusframework/actions/EchoAction.java @@ -41,7 +41,7 @@ public class EchoAction extends AbstractTestAction { * Default constructor using the builder. * @param builder */ - private EchoAction(EchoAction.Builder builder) { + private EchoAction(Builder builder) { super("echo", builder); this.message = builder.message; diff --git a/pom.xml b/pom.xml index 4e91215c66..ef0a629496 100644 --- a/pom.xml +++ b/pom.xml @@ -54,8 +54,8 @@ utils runtime validation - endpoints connectors + endpoints tools catalog @@ -194,6 +194,7 @@ 1.1.27 1.8.0 3.25.1 + 4.2.1 1.78.1 1.15.1 2.12.0 @@ -581,6 +582,12 @@ ${apicurio.data-models.version} + + org.awaitility + awaitility + ${awaitility.version} + + org.eclipse.jetty diff --git a/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase-4.4.0-SNAPSHOT.xsd b/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase-4.4.0-SNAPSHOT.xsd index a52a3b622c..39982c0142 100644 --- a/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase-4.4.0-SNAPSHOT.xsd +++ b/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase-4.4.0-SNAPSHOT.xsd @@ -727,6 +727,7 @@ + @@ -1311,6 +1312,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase.xsd b/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase.xsd index a52a3b622c..39982c0142 100644 --- a/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase.xsd +++ b/runtime/citrus-xml/src/main/resources/org/citrusframework/schema/xml/testcase/citrus-testcase.xsd @@ -727,6 +727,7 @@ + @@ -1311,6 +1312,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/assembly/dist-antlibs.xml b/src/main/assembly/dist-antlibs.xml index af6146b6ac..5add9732cc 100644 --- a/src/main/assembly/dist-antlibs.xml +++ b/src/main/assembly/dist-antlibs.xml @@ -54,6 +54,7 @@ org.citrusframework:citrus-selenium org.citrusframework:citrus-kubernetes org.citrusframework:citrus-sql + org.citrusframework:citrus-jbang-connector org.citrusframework:citrus-restdocs org.citrusframework:citrus-maven-plugin diff --git a/src/main/assembly/dist-release.xml b/src/main/assembly/dist-release.xml index e3e37ea4f6..7b14de3044 100644 --- a/src/main/assembly/dist-release.xml +++ b/src/main/assembly/dist-release.xml @@ -50,6 +50,7 @@ org.citrusframework:citrus-selenium org.citrusframework:citrus-kubernetes org.citrusframework:citrus-sql + org.citrusframework:citrus-jbang-connector org.citrusframework:citrus-restdocs org.citrusframework:citrus-maven-plugin @@ -321,6 +322,13 @@ *sources.jar + + connectors/citrus-jbang-connector/target + src + + *sources.jar + + connectors/citrus-openapi/target src diff --git a/src/main/assembly/dist-sources.xml b/src/main/assembly/dist-sources.xml index b63714d824..2beb23316c 100644 --- a/src/main/assembly/dist-sources.xml +++ b/src/main/assembly/dist-sources.xml @@ -263,6 +263,13 @@ *sources.jar + + connectors/citrus-jbang-connector/target + + + *sources.jar + + connectors/citrus-openapi/target diff --git a/src/manual/connector-jbang.adoc b/src/manual/connector-jbang.adoc new file mode 100644 index 0000000000..c508d95491 --- /dev/null +++ b/src/manual/connector-jbang.adoc @@ -0,0 +1,89 @@ +[[jbang]] +== JBang support + +With https://jbang.dev/[JBang] you can run your `.java` files directly in your shell - instantly without tedious setup. + +NOTE: The JBang support in Citrus get enabled by adding a separate Maven module as dependency to your project + +[source,xml] +---- + + org.citrusframework + citrus-jbang-connector + ${citrus.version} + +---- + +NOTE: You may wonder why is there another module named `citrus-jbang`! What is the difference compared to the `citrus-jbang-connector` module? The `citrus-jbang` module represents the Citrus JBang app that you can use in JBang to run Citrus tests without any prior setup. The `citrus-jbang-connector` module provides the JBang support to use JBang as part of a test case, for instance in the form of a test action that runs JBang scripts. + +[[jbang-action]] +=== JBang action + +The JBang test action runs a script or JBang application with a spawned process. +You can call any JBang CLI command and run your JBang app. +JBang is called with the Java process API so the JBang CLI binary is executed and the command output is saved for later reference. +You can verify the command output with an expected output and you can also verify the exit code of the spawned process. + +.Java +[source,java,indent=0,role="primary"] +---- +@CitrusTest +public void jBangScriptTest() { + when(jbang() + .app("myApp") + .command("getUsers") + .arg("--username", "FooUser")); +} +---- + +.XML +[source,xml,indent=0,role="secondary"] +---- + + + + + + + + + +---- + +.YAML +[source,yaml,indent=0,role="secondary"] +---- +name: JBangScriptTest +actions: + - jbang: + app: "myApp" + command: "getUsers" + args: + - name: "--username" + value: "FooUser" +---- + +.Spring XML +[source,xml,indent=0,role="secondary"] +---- + + + +---- + +The test above calls the JBang app `myApp` with the `getUsers` command. +The call is similar to a command line statement like this: + +[source,text] +---- +$ jbang myApp getUsers --username=FooUser +---- + +The app `myApp` represents a JBang application that has been installed previously in JBang (for instance from a GitHub repository that holds a `jbang-catalog.json`): + +[source,text] +---- +$ jbang app install myApp@github-user/repository +---- + diff --git a/src/manual/connectors.adoc b/src/manual/connectors.adoc index 4d1c3b8253..ed8c7a33bd 100644 --- a/src/manual/connectors.adoc +++ b/src/manual/connectors.adoc @@ -7,3 +7,4 @@ These modules connect Citrus to a certain technology or framework rather than im Connectors typically provide a client side only implementation that enable Citrus to interact with a service or framework (e.g. Docker deamon, Selenium web driver, OpenAPI specification). include::connector-openapi.adoc[] +include::connector-jbang.adoc[]