From ef99352cafcc1c7e544df0973d8c89670a045eb6 Mon Sep 17 00:00:00 2001 From: Georgios Andrianakis Date: Mon, 17 Feb 2020 01:18:56 +0200 Subject: [PATCH] Introduce QuarkusProdModeTest --- build-parent/pom.xml | 5 + independent-projects/tools/common/pom.xml | 4 + independent-projects/tools/pom.xml | 6 + independent-projects/tools/utilities/pom.xml | 17 + .../io/quarkus/utilities/JavaBinFinder.java | 0 .../test/common/RestAssuredURLManager.java | 10 +- test-framework/junit5-internal/pom.xml | 4 + .../main/java/io/quarkus/test/LogFile.java | 17 + .../io/quarkus/test/ProdBuildResults.java | 14 + .../io/quarkus/test/ProdModeTestResults.java | 31 ++ .../io/quarkus/test/QuarkusProdModeTest.java | 448 ++++++++++++++++++ 11 files changed, 554 insertions(+), 2 deletions(-) create mode 100644 independent-projects/tools/utilities/pom.xml rename independent-projects/tools/{common => utilities}/src/main/java/io/quarkus/utilities/JavaBinFinder.java (100%) create mode 100644 test-framework/junit5-internal/src/main/java/io/quarkus/test/LogFile.java create mode 100644 test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdBuildResults.java create mode 100644 test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdModeTestResults.java create mode 100644 test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusProdModeTest.java diff --git a/build-parent/pom.xml b/build-parent/pom.xml index 3e3351b5f..aac1845d0 100644 --- a/build-parent/pom.xml +++ b/build-parent/pom.xml @@ -102,6 +102,11 @@ + + io.quarkus + quarkus-devtools-utilities + ${project.version} + io.quarkus quarkus-devtools-common diff --git a/independent-projects/tools/common/pom.xml b/independent-projects/tools/common/pom.xml index 998469038..c2e5afb7f 100644 --- a/independent-projects/tools/common/pom.xml +++ b/independent-projects/tools/common/pom.xml @@ -31,6 +31,10 @@ quarkus-bootstrap-core provided + + io.quarkus + quarkus-devtools-utilities + io.quarkus quarkus-platform-descriptor-api diff --git a/independent-projects/tools/pom.xml b/independent-projects/tools/pom.xml index cc43f99b2..8907700df 100644 --- a/independent-projects/tools/pom.xml +++ b/independent-projects/tools/pom.xml @@ -54,6 +54,7 @@ platform-descriptor-api common + utilities platform-descriptor-resolver-json @@ -68,6 +69,11 @@ quarkus-bootstrap-core ${quarkus.version} + + io.quarkus + quarkus-devtools-utilities + ${project.version} + io.quarkus quarkus-devtools-common diff --git a/independent-projects/tools/utilities/pom.xml b/independent-projects/tools/utilities/pom.xml new file mode 100644 index 000000000..0193e8654 --- /dev/null +++ b/independent-projects/tools/utilities/pom.xml @@ -0,0 +1,17 @@ + + + 4.0.0 + + + io.quarkus + quarkus-tools-parent + 999-SNAPSHOT + + + quarkus-devtools-utilities + Quarkus - Dev tools - Utilities + + + \ No newline at end of file diff --git a/independent-projects/tools/common/src/main/java/io/quarkus/utilities/JavaBinFinder.java b/independent-projects/tools/utilities/src/main/java/io/quarkus/utilities/JavaBinFinder.java similarity index 100% rename from independent-projects/tools/common/src/main/java/io/quarkus/utilities/JavaBinFinder.java rename to independent-projects/tools/utilities/src/main/java/io/quarkus/utilities/JavaBinFinder.java diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredURLManager.java b/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredURLManager.java index 1fdd0ea42..d2d798614 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredURLManager.java +++ b/test-framework/common/src/main/java/io/quarkus/test/common/RestAssuredURLManager.java @@ -55,11 +55,17 @@ public class RestAssuredURLManager { } public static void setURL(boolean useSecureConnection) { + setURL(useSecureConnection, null); + } + + public static void setURL(boolean useSecureConnection, Integer port) { if (portField != null) { try { oldPort = (Integer) portField.get(null); - int port = useSecureConnection ? getPortFromConfig("quarkus.https.test-port", DEFAULT_HTTPS_PORT) - : getPortFromConfig("quarkus.http.test-port", DEFAULT_HTTP_PORT); + if (port == null) { + port = useSecureConnection ? getPortFromConfig("quarkus.https.test-port", DEFAULT_HTTPS_PORT) + : getPortFromConfig("quarkus.http.test-port", DEFAULT_HTTP_PORT); + } portField.set(null, port); } catch (IllegalAccessException e) { e.printStackTrace(); diff --git a/test-framework/junit5-internal/pom.xml b/test-framework/junit5-internal/pom.xml index 89028d579..f950897a0 100644 --- a/test-framework/junit5-internal/pom.xml +++ b/test-framework/junit5-internal/pom.xml @@ -56,6 +56,10 @@ io.quarkus quarkus-development-mode + + io.quarkus + quarkus-devtools-utilities + diff --git a/test-framework/junit5-internal/src/main/java/io/quarkus/test/LogFile.java b/test-framework/junit5-internal/src/main/java/io/quarkus/test/LogFile.java new file mode 100644 index 000000000..c8c524c08 --- /dev/null +++ b/test-framework/junit5-internal/src/main/java/io/quarkus/test/LogFile.java @@ -0,0 +1,17 @@ +package io.quarkus.test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marker annotation to be used on a {@link java.nio.file.Path} field inside a test + * to get the log file injected into the test. + * + * The Path will field only be set if the application has actually been started with a logfile configured. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface LogFile { +} diff --git a/test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdBuildResults.java b/test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdBuildResults.java new file mode 100644 index 000000000..b3fdada18 --- /dev/null +++ b/test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdBuildResults.java @@ -0,0 +1,14 @@ +package io.quarkus.test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marker annotation that indicates that {@link ProdModeTestResults} should be injected into a test + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface ProdBuildResults { +} diff --git a/test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdModeTestResults.java b/test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdModeTestResults.java new file mode 100644 index 000000000..2e42c13f7 --- /dev/null +++ b/test-framework/junit5-internal/src/main/java/io/quarkus/test/ProdModeTestResults.java @@ -0,0 +1,31 @@ +package io.quarkus.test; + +import java.nio.file.Path; +import java.util.List; + +import io.quarkus.bootstrap.app.ArtifactResult; + +public class ProdModeTestResults { + + private final Path buildDir; + private final Path builtArtifactPath; + private final List results; + + public ProdModeTestResults(Path buildDir, Path builtArtifactPath, List results) { + this.buildDir = buildDir; + this.builtArtifactPath = builtArtifactPath; + this.results = results; + } + + public Path getBuildDir() { + return buildDir; + } + + public Path getBuiltArtifactPath() { + return builtArtifactPath; + } + + public List getResults() { + return results; + } +} diff --git a/test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusProdModeTest.java b/test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusProdModeTest.java new file mode 100644 index 000000000..0472a874f --- /dev/null +++ b/test-framework/junit5-internal/src/main/java/io/quarkus/test/QuarkusProdModeTest.java @@ -0,0 +1,448 @@ +package io.quarkus.test; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.lang.reflect.Field; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Properties; +import java.util.Timer; +import java.util.TimerTask; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.Asset; +import org.jboss.shrinkwrap.api.exporter.ExplodedExporter; +import org.jboss.shrinkwrap.api.exporter.ZipExporter; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +import io.quarkus.bootstrap.app.AugmentAction; +import io.quarkus.bootstrap.app.AugmentResult; +import io.quarkus.bootstrap.app.CuratedApplication; +import io.quarkus.bootstrap.app.QuarkusBootstrap; +import io.quarkus.test.common.PathTestHelper; +import io.quarkus.test.common.RestAssuredURLManager; +import io.quarkus.utilities.JavaBinFinder; + +/** + * A test extension for producing a prod-mode jar. This is meant to be used by extension authors, it's not intended for end user + * consumption + */ +public class QuarkusProdModeTest + implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback { + + private static final String EXPECTED_OUTPUT_FROM_SUCCESSFULLY_STARTED = "features"; + private static final int DEFAULT_HTTP_PORT_INT = 8081; + private static final String DEFAULT_HTTP_PORT = "" + DEFAULT_HTTP_PORT_INT; + private static final String QUARKUS_HTTP_PORT_PROPERTY = "quarkus.http.port"; + + static { + System.setProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager"); + } + + private Path outputDir; + private Supplier archiveProducer; + private String applicationName; + private String applicationVersion; + private boolean buildNative; + + private static final Timer timeoutTimer = new Timer("Test thread dump timer"); + private volatile TimerTask timeoutTask; + private Properties customApplicationProperties; + private CuratedApplication curatedApplication; + + private boolean run; + + private String logFileName; + private Map runtimeProperties; + + private Process process; + + private ProdModeTestResults prodModeTestResults; + private Optional prodModeTestResultsField = Optional.empty(); + private Path logfilePath; + private Optional logfileField = Optional.empty(); + + public Supplier getArchiveProducer() { + return archiveProducer; + } + + public QuarkusProdModeTest setArchiveProducer(Supplier archiveProducer) { + Objects.requireNonNull(archiveProducer); + this.archiveProducer = archiveProducer; + return this; + } + + /** + * Effectively sets the quarkus.application.name property. + * This value will override quarkus.application.name if that has been set in the configuration properties + */ + public QuarkusProdModeTest setApplicationName(String applicationName) { + this.applicationName = applicationName; + return this; + } + + /** + * Effectively sets the quarkus.application.version property. + * This value will override quarkus.application.version if that has been set in the configuration properties + */ + public QuarkusProdModeTest setApplicationVersion(String applicationVersion) { + this.applicationVersion = applicationVersion; + return this; + } + + /** + * Effectively sets the quarkus.packaging.type property. + * This value will override quarkus.packaging.type if that has been set in the configuration properties + */ + public QuarkusProdModeTest setBuildNative(boolean buildNative) { + this.buildNative = buildNative; + return this; + } + + /** + * If set to true, the built artifact will be run before starting the tests + */ + public QuarkusProdModeTest setRun(boolean run) { + this.run = run; + return this; + } + + /** + * File where the running application logs its output + * This property effectively sets the quarkus.log.file.path runtime configuration property + * and will override that value if it has been set in the configuration properties of the test + */ + public QuarkusProdModeTest setLogFileName(String logFileName) { + this.logFileName = logFileName; + return this; + } + + /** + * The runtime configuration properties to be used if the built artifact is configured to be run + */ + public QuarkusProdModeTest setRuntimeProperties(Map runtimeProperties) { + this.runtimeProperties = runtimeProperties; + return this; + } + + private void exportArchive(Path deploymentDir, Class testClass) { + try { + JavaArchive archive = getArchiveProducerOrDefault(); + if (customApplicationProperties != null) { + archive.add(new PropertiesAsset(customApplicationProperties), "application.properties"); + } + archive.as(ExplodedExporter.class).exportExplodedInto(deploymentDir.toFile()); + + String exportPath = System.getProperty("quarkus.deploymentExportPath"); + if (exportPath != null) { + File exportDir = new File(exportPath); + if (exportDir.exists()) { + if (!exportDir.isDirectory()) { + throw new IllegalStateException("Export path is not a directory: " + exportPath); + } + try (Stream stream = Files.walk(exportDir.toPath())) { + stream.sorted(Comparator.reverseOrder()).map(Path::toFile) + .forEach(File::delete); + } + } else if (!exportDir.mkdirs()) { + throw new IllegalStateException("Export path could not be created: " + exportPath); + } + File exportFile = new File(exportDir, archive.getName()); + archive.as(ZipExporter.class).exportTo(exportFile); + } + } catch (Exception e) { + throw new RuntimeException("Unable to create the archive", e); + } + } + + private JavaArchive getArchiveProducerOrDefault() { + if (archiveProducer == null) { + return ShrinkWrap.create(JavaArchive.class); + } else { + return archiveProducer.get(); + } + } + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + timeoutTask = new TimerTask() { + @Override + public void run() { + System.err.println("Test has been running for more than 5 minutes, thread dump is:"); + for (Map.Entry i : Thread.getAllStackTraces().entrySet()) { + System.err.println("\n"); + System.err.println(i.toString()); + System.err.println("\n"); + for (StackTraceElement j : i.getValue()) { + System.err.println(j); + } + } + } + }; + timeoutTimer.schedule(timeoutTask, 1000 * 60 * 5); + + Class testClass = extensionContext.getRequiredTestClass(); + + try { + outputDir = Files.createTempDirectory("quarkus-prod-mode-test"); + Path deploymentDir = outputDir.resolve("deployment"); + Path buildDir = outputDir.resolve("build"); + + if (applicationName != null) { + overrideConfigKey("quarkus.application.name", applicationName); + } + if (applicationVersion != null) { + overrideConfigKey("quarkus.application.version", applicationVersion); + } + if (buildNative) { + overrideConfigKey("quarkus.package.type", "native"); + } + exportArchive(deploymentDir, testClass); + + Path testLocation = PathTestHelper.getTestClassesLocation(testClass); + try { + QuarkusBootstrap.Builder builder = QuarkusBootstrap.builder(deploymentDir) + .setMode(QuarkusBootstrap.Mode.PROD) + .setLocalProjectDiscovery(true) + .addExcludedPath(testLocation) + .setProjectRoot(testLocation) + .setTargetDirectory(buildDir); + if (applicationName != null) { + builder.setBaseName(applicationName); + } + curatedApplication = builder.build().bootstrap(); + + AugmentAction action = curatedApplication.createAugmentor(); + AugmentResult result = action.createProductionApplication(); + + Path builtResultArtifact = setupProdModeResults(testClass, buildDir, result); + + if (run) { + startBuiltResult(builtResultArtifact); + RestAssuredURLManager.setURL(false, + runtimeProperties.get(QUARKUS_HTTP_PORT_PROPERTY) != null + ? Integer.parseInt(runtimeProperties.get(QUARKUS_HTTP_PORT_PROPERTY)) + : DEFAULT_HTTP_PORT_INT); + + if (logfilePath != null) { + logfileField = Arrays.stream(testClass.getDeclaredFields()).filter( + f -> f.isAnnotationPresent(LogFile.class) && Path.class.equals(f.getType())) + .findAny(); + logfileField.ifPresent(f -> f.setAccessible(true)); + } + } + + } catch (Throwable e) { + throw e; + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Path setupProdModeResults(Class testClass, Path buildDir, AugmentResult result) { + prodModeTestResultsField = Arrays.stream(testClass.getDeclaredFields()).filter( + f -> f.isAnnotationPresent(ProdBuildResults.class) && ProdModeTestResults.class.equals(f.getType())) + .findAny(); + prodModeTestResultsField.ifPresent(f -> f.setAccessible(true)); + + Path builtResultArtifact = result.getNativeResult(); + if (builtResultArtifact == null) { + builtResultArtifact = result.getJar().getPath(); + } + + prodModeTestResults = new ProdModeTestResults(buildDir, builtResultArtifact, result.getResults()); + return builtResultArtifact; + } + + private void startBuiltResult(Path builtResultArtifact) throws IOException { + Path builtResultArtifactParentDir = builtResultArtifact.getParent(); + + if (runtimeProperties == null) { + runtimeProperties = new HashMap<>(); + } else { + // copy the use supplied properties since it might an immutable map + runtimeProperties = new HashMap<>(runtimeProperties); + } + runtimeProperties.putIfAbsent(QUARKUS_HTTP_PORT_PROPERTY, DEFAULT_HTTP_PORT); + if (logFileName != null) { + logfilePath = builtResultArtifactParentDir.resolve(logFileName); + runtimeProperties.put("quarkus.log.file.path", logfilePath.toAbsolutePath().toString()); + runtimeProperties.put("quarkus.log.file.enable", "true"); + } + List systemProperties = runtimeProperties.entrySet().stream() + .map(e -> "-D" + e.getKey() + "=" + e.getValue()).collect(Collectors.toList()); + List command = new ArrayList<>(systemProperties.size() + 3); + if (builtResultArtifact.getFileName().toString().endsWith(".jar")) { + command.add(JavaBinFinder.findBin()); + command.addAll(systemProperties); + command.add("-jar"); + command.add(builtResultArtifact.toAbsolutePath().toString()); + } else { + command.add(builtResultArtifact.toAbsolutePath().toString()); + command.addAll(systemProperties); + } + + process = new ProcessBuilder(command) + .redirectErrorStream(true) + .directory(builtResultArtifactParentDir.toFile()) + .start(); + ensureApplicationStartupOrFailure(); + } + + private void ensureApplicationStartupOrFailure() throws IOException { + BufferedReader in = new BufferedReader(new InputStreamReader(process.getInputStream())); + while (true) { + if (!process.isAlive()) { + in.close(); + throw new RuntimeException( + "The produced jar could not be launched. Consult the above output for the exact cause."); + } + String line = in.readLine(); + if (line != null) { + System.out.println(line); + if (line.contains(EXPECTED_OUTPUT_FROM_SUCCESSFULLY_STARTED)) { + in.close(); + break; + } + } + } + } + + @Override + public void afterAll(ExtensionContext extensionContext) throws Exception { + if (run) { + RestAssuredURLManager.clearURL(); + } + + try { + if (process != null) { + process.destroy(); + process.waitFor(); + } + } catch (InterruptedException ignored) { + + } + + try { + curatedApplication.close(); + } finally { + timeoutTask.cancel(); + timeoutTask = null; + + if (outputDir != null) { + Files.walkFileTree(outputDir, new FileVisitor() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + Files.delete(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) throws IOException { + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (exc == null) { + Files.delete(dir); + return FileVisitResult.CONTINUE; + } else { + throw exc; + } + } + }); + } + } + } + + @Override + public void beforeEach(ExtensionContext context) { + prodModeTestResultsField.ifPresent(f -> { + try { + f.set(context.getRequiredTestInstance(), prodModeTestResults); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + }); + + logfileField.ifPresent(f -> { + try { + f.set(context.getRequiredTestInstance(), logfilePath); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + }); + } + + public QuarkusProdModeTest withConfigurationResource(String resourceName) { + if (customApplicationProperties == null) { + customApplicationProperties = new Properties(); + } + try { + try (InputStream in = ClassLoader.getSystemResourceAsStream(resourceName)) { + customApplicationProperties.load(in); + } + return this; + } catch (IOException e) { + throw new RuntimeException("Could not load resource: '" + resourceName + "'"); + } + } + + public QuarkusProdModeTest overrideConfigKey(final String propertyKey, final String propertyValue) { + if (customApplicationProperties == null) { + customApplicationProperties = new Properties(); + } + customApplicationProperties.put(propertyKey, propertyValue); + return this; + } + + private static class PropertiesAsset implements Asset { + private final Properties props; + + public PropertiesAsset(final Properties props) { + this.props = props; + } + + @Override + public InputStream openStream() { + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(128); + try { + props.store(outputStream, "Unit test Generated Application properties"); + } catch (IOException e) { + throw new RuntimeException("Could not write application properties resource", e); + } + return new ByteArrayInputStream(outputStream.toByteArray()); + } + } +}