diff --git a/rapier-core/src/test/java/rapier/core/RapierTestBase.java b/rapier-core/src/test/java/rapier/core/RapierTestBase.java new file mode 100644 index 0000000..9aa386e --- /dev/null +++ b/rapier-core/src/test/java/rapier/core/RapierTestBase.java @@ -0,0 +1,326 @@ +/*- + * =================================LICENSE_START================================== + * rapier-core + * ====================================SECTION===================================== + * Copyright (C) 2024 Andy Boothe + * ====================================SECTION===================================== + * 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. + * ==================================LICENSE_END=================================== + */ +package rapier.core; + +import static java.util.Collections.unmodifiableList; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.ServiceLoader; +import java.util.regex.Pattern; +import javax.annotation.processing.Processor; +import javax.tools.JavaFileObject; +import com.google.testing.compile.Compilation; +import com.google.testing.compile.Compiler; +import com.google.testing.compile.JavaFileObjects; +import rapier.core.util.Maven; + +/** + * Base class for Rapier tests. + * + *

+ * Any {@code public} methods are for use in test classes. Any {@code protected} methods are for + * override in subclasses. Any {@code private} methods are for internal use only, and are not + * visible to test classes anyway. + */ +public abstract class RapierTestBase { + public Compilation doCompile(JavaFileObject... source) throws IOException { + return Compiler.javac().withClasspath(getCompileClasspath()) + .withProcessors(getAnnotationProcessors()).compile(source); + } + + public String doRun(Compilation compilation) throws IOException { + final List classpathAsFiles = getRunClasspath(compilation); + + final List classpathAsUrls = new ArrayList<>(); + for (File file : classpathAsFiles) + classpathAsUrls.add(file.toURI().toURL()); + + final List mains = new ArrayList<>(); + compilation.sourceFiles().stream().filter(RapierTestBase::containsMainMethod) + .forEach(mains::add); + compilation.generatedSourceFiles().stream().filter(RapierTestBase::containsMainMethod) + .forEach(mains::add); + if (mains.isEmpty()) + throw new IllegalArgumentException("No main method found"); + if (mains.size() > 1) + throw new IllegalArgumentException("Multiple main methods found"); + final JavaFileObject main = mains.get(0); + + try (URLClassLoader classLoader = new URLClassLoader(classpathAsUrls.toArray(URL[]::new), + ClassLoader.getPlatformClassLoader())) { + + final Class mainClass = classLoader.loadClass(toQualifiedClassName(main)); + + // Run the main method of the specified class + final Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); + + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + synchronized (System.class) { + final PrintStream stdout0 = System.out; + final PrintStream stderr0 = System.err; + try { + System.setOut(new PrintStream(stdout)); + System.setErr(new PrintStream(stderr)); + mainMethod.invoke(null, (Object) new String[] {}); + } finally { + System.setOut(stdout0); + System.setErr(stderr0); + } + } + + final byte[] stdoutBytes = stdout.toByteArray(); + final byte[] stderrBytes = stderr.toByteArray(); + + if (stderrBytes.length > 0) + System.err.write(stderrBytes); + + return new String(stdoutBytes, StandardCharsets.UTF_8); + } catch (RuntimeException e) { + throw e; + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InvocationTargetException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) + throw (RuntimeException) cause; + if (cause instanceof IOException) + throw new UncheckedIOException((IOException) cause); + throw new IllegalArgumentException( + "Execution of invalid compilation units failed with exception", cause); + } catch (Exception e) { + throw new IllegalArgumentException( + "Execution of invalid compilation units failed with exception", e); + } + } + + /** + * Check if a JavaFileObject contains a main method. A main method is considered to be present if + * the file contains the literal string {@code "public static void main(String[] args)"}. + * + * @param file the file to check + * @return {@code true} if the file contains a main method, {@code false} otherwise + * @throws UncheckedIOException if an IOException occurs while reading the file + */ + private static boolean containsMainMethod(JavaFileObject file) { + try { + return file.getCharContent(true).toString() + .contains("public static void main(String[] args)"); + } catch (IOException e) { + // This really ought never to happen, since it's all in memory. + throw new UncheckedIOException("Failed to read JavaFileObject contents", e); + } + } + + /** + * Extracts the qualified Java class name from a JavaFileObject. + * + * @param file the JavaFileObject representing the Java source code + * @return the qualified class name + */ + private static String toQualifiedClassName(JavaFileObject file) { + // Get the name of the JavaFileObject + String fileName = file.getName(); + + // Remove directories or prefixes before the package + // Example: "/com/example/HelloWorld.java" + // becomes "com/example/HelloWorld.java" + if (fileName.startsWith("/")) { + fileName = fileName.substring(1); + } + + // Remove the ".java" extension + if (fileName.endsWith(".java")) { + fileName = fileName.substring(0, fileName.length() - ".java".length()); + } + + // Replace '/' with '.' to form package and class hierarchy + fileName = fileName.replace("/", "."); + + return fileName; + } + + private static final Pattern PACKAGE_DECLARATION_PATTERN = + Pattern.compile("^package\\s+(\\S+)\\s*;", Pattern.MULTILINE); + private static final Pattern CLASS_DECLARATION_PATTERN = + Pattern.compile("^public\\s+(?:class|interface)\\s+(\\S+)\\s*\\{", Pattern.MULTILINE); + + public JavaFileObject prepareSourceFile(String sourceCode) { + final String packageName = PACKAGE_DECLARATION_PATTERN.matcher(sourceCode).results().findFirst() + .map(m -> m.group(1)).orElse(null); + + final String simpleClassName = CLASS_DECLARATION_PATTERN.matcher(sourceCode).results() + .findFirst().map(m -> m.group(1)).orElse(null); + if (simpleClassName == null) + throw new IllegalArgumentException("Failed to detect class name"); + + final String qualifiedClassName = + packageName != null ? packageName + "." + simpleClassName : simpleClassName; + + return JavaFileObjects.forSourceString(qualifiedClassName, sourceCode); + } + + /** + * The root directory of the current Maven module + */ + private static final File MAVEN_PROJECT_BASEDIR = + Optional.ofNullable(System.getProperty("maven.project.basedir")).map(File::new).orElseThrow( + () -> new IllegalStateException("maven.project.basedir system property not set")); + + public File resolveProjectFile(String path) throws FileNotFoundException { + final File result = new File(MAVEN_PROJECT_BASEDIR, path); + if (!result.exists()) + throw new FileNotFoundException(result.toString()); + return result; + } + + protected List getRunClasspath(Compilation compilation) throws IOException { + List result = new ArrayList<>(); + + // We need everything on the compile classpath + result.addAll(getCompileClasspath()); + + // Extract the compiled files into a temporary directory. + final Path tmpdir = Files.createTempDirectory("test"); + for (JavaFileObject file : compilation.generatedFiles()) { + if (file.getKind() == JavaFileObject.Kind.CLASS) { + final String originalClassFileName = file.getName(); + + String sanitizedClassFileName = originalClassFileName; + sanitizedClassFileName = sanitizedClassFileName.replace("/", File.separator); + if (sanitizedClassFileName.startsWith(File.separator)) + sanitizedClassFileName = sanitizedClassFileName.substring(1); + if (sanitizedClassFileName.startsWith("CLASS_OUTPUT" + File.separator)) + sanitizedClassFileName = sanitizedClassFileName.substring("CLASS_OUTPUT".length() + 1, + sanitizedClassFileName.length()); + + final Path tmpdirClassFile = tmpdir.resolve(sanitizedClassFileName); + + Files.createDirectories(tmpdirClassFile.getParent()); + + try (InputStream in = file.openInputStream()) { + Files.copy(in, tmpdirClassFile); + } + } + } + + // We also want the compiled classes to actually run the application + final File tmpdirAsFile = tmpdir.toFile(); + result.add(tmpdirAsFile); + tmpdirAsFile.deleteOnExit(); + + return unmodifiableList(result); + } + + protected List getCompileClasspath() throws FileNotFoundException { + final File daggerJar = + Maven.findJarInLocalRepository("com.google.dagger", "dagger", DAGGER_VERSION); + final File javaxInjectJar = + Maven.findJarInLocalRepository("javax.inject", "javax.inject", JAVAX_INJECT_VERSION); + final File jakartaInjectApiJar = Maven.findJarInLocalRepository("jakarta.inject", + "jakarta.inject-api", JAKARTA_INJECT_API_VERSION); + final File jsr305Jar = + Maven.findJarInLocalRepository("com.google.code.findbugs", "jsr305", JSR_305_VERSION); + return List.of(daggerJar, javaxInjectJar, jakartaInjectApiJar, jsr305Jar); + } + + /** + * The Dagger version to use for compiling test code. This should be passed by + * maven-surefire-plugin using the exact dagger version from the POM. See the root POM for the + * specific details of the setup. + */ + private static final String DAGGER_VERSION = + Optional.ofNullable(System.getProperty("maven.dagger.version")).orElseThrow( + () -> new IllegalStateException("maven.dagger.version system property not set")); + + /** + * The javax.inject version to use for compiling test code. This should be passed by + * maven-surefire-plugin using the exact javax.inject version from the POM. See the root POM for + * the specific details of the setup. + */ + private static final String JAVAX_INJECT_VERSION = + Optional.ofNullable(System.getProperty("maven.javax.inject.version")).orElseThrow( + () -> new IllegalStateException("maven.javax.inject.version system property not set")); + + /** + * The Jakarta Inject API version to use for compiling test code. This should be passed by + * maven-surefire-plugin using the exact Jakarta Inject API version from the POM. See the root POM + * for the specific details + */ + private static final String JAKARTA_INJECT_API_VERSION = + Optional.ofNullable(System.getProperty("maven.jakarta.inject-api.version")) + .orElseThrow(() -> new IllegalStateException( + "maven.jakarta.inject-api.version system property not set")); + + private static final String JSR_305_VERSION = + Optional.ofNullable(System.getProperty("maven.jsr305.version")).orElseThrow( + () -> new IllegalStateException("maven.jsr305.version system property not set")); + + /** + * Returns the annotation processors to use when compiling the source code. We will use any + * annotation available via ServiceLoader, plus the Dagger processor. + */ + protected List getAnnotationProcessors() { + final List result = new ArrayList<>(); + for (Processor processor : ServiceLoader.load(Processor.class)) + result.add(processor); + + // We only want to run dagger and rapier processors. + final Iterator iterator = result.iterator(); + while (iterator.hasNext()) { + final Processor processor = iterator.next(); + final String processorClassName = processor.getClass().getName(); + + // Skip any annotation processors we don't recognize + if (processorClassName.startsWith("dagger.")) { + // This is a dagger processor. Let's keep it, obviously. + } else if (processorClassName.startsWith("rapier.")) { + // This is a rapier processor. Let's keep it, obviously. + } else if (processorClassName.startsWith("com.google.")) { + // We want to see if rapier and dagger work on a standalone basis. + // We recognize and trust these, but let's skip them. + iterator.remove(); + } else { + // What are you...? + System.err + .println("WARNING: Skipping unrecognized annotation processor: " + processorClassName); + iterator.remove(); + } + } + + return unmodifiableList(result); + } +} diff --git a/rapier-environment-variable-compiler/src/test/java/rapier/envvar/compiler/EnvironmentVariableProcessorTest.java b/rapier-environment-variable-compiler/src/test/java/rapier/envvar/compiler/EnvironmentVariableProcessorTest.java index f3ebe2c..73e0f11 100644 --- a/rapier-environment-variable-compiler/src/test/java/rapier/envvar/compiler/EnvironmentVariableProcessorTest.java +++ b/rapier-environment-variable-compiler/src/test/java/rapier/envvar/compiler/EnvironmentVariableProcessorTest.java @@ -23,29 +23,19 @@ import static java.util.Collections.unmodifiableList; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.PrintStream; -import java.io.UncheckedIOException; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.net.URL; -import java.net.URLClassLoader; -import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.Locale; -import java.util.regex.Pattern; import javax.tools.JavaFileObject; import org.junit.jupiter.api.Test; import com.google.testing.compile.Compilation; -import com.google.testing.compile.Compiler; import com.google.testing.compile.JavaFileObjects; -import rapier.core.DaggerTestBase; +import rapier.core.RapierTestBase; -public class EnvironmentVariableProcessorTest extends DaggerTestBase { +public class EnvironmentVariableProcessorTest extends RapierTestBase { @Test public void givenSimpleComponentWithEnvironmentVariableWithoutDefaultValue_whenCompile_thenExpectedtModuleIsGenerated() throws IOException { @@ -325,144 +315,4 @@ protected List getCompileClasspath() throws FileNotFoundException { result.add(resolveProjectFile("../rapier-environment-variable/target/classes")); return unmodifiableList(result); } - - // UTILITY /////////////////////////////////////////////////////////////////////////////////////// - - - private Compilation doCompile(JavaFileObject... source) throws IOException { - return Compiler.javac().withClasspath(getCompileClasspath()) - .withProcessors(getAnnotationProcessors()).compile(source); - } - - private String doRun(Compilation compilation) throws IOException { - final List classpathAsFiles = getRunClasspath(compilation); - - final List classpathAsUrls = new ArrayList<>(); - for (File file : classpathAsFiles) - classpathAsUrls.add(file.toURI().toURL()); - - final List mains = new ArrayList<>(); - compilation.sourceFiles().stream().filter(EnvironmentVariableProcessorTest::containsMainMethod) - .forEach(mains::add); - compilation.generatedSourceFiles().stream() - .filter(EnvironmentVariableProcessorTest::containsMainMethod).forEach(mains::add); - if (mains.isEmpty()) - throw new IllegalArgumentException("No main method found"); - if (mains.size() > 1) - throw new IllegalArgumentException("Multiple main methods found"); - final JavaFileObject main = mains.get(0); - - try (URLClassLoader classLoader = new URLClassLoader(classpathAsUrls.toArray(URL[]::new), - ClassLoader.getPlatformClassLoader())) { - - final Class mainClass = classLoader.loadClass(toQualifiedClassName(main)); - - // Run the main method of the specified class - final Method mainMethod = mainClass.getDeclaredMethod("main", String[].class); - - final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - synchronized (System.class) { - final PrintStream stdout0 = System.out; - final PrintStream stderr0 = System.err; - try { - System.setOut(new PrintStream(stdout)); - System.setErr(new PrintStream(stderr)); - mainMethod.invoke(null, (Object) new String[] {}); - } finally { - System.setOut(stdout0); - System.setErr(stderr0); - } - } - - final byte[] stdoutBytes = stdout.toByteArray(); - final byte[] stderrBytes = stderr.toByteArray(); - - if (stderrBytes.length > 0) - System.err.write(stderrBytes); - - return new String(stdoutBytes, StandardCharsets.UTF_8); - } catch (RuntimeException e) { - throw e; - } catch (IOException e) { - throw new UncheckedIOException(e); - } catch (InvocationTargetException e) { - Throwable cause = e.getCause(); - if (cause instanceof RuntimeException) - throw (RuntimeException) cause; - if (cause instanceof IOException) - throw new UncheckedIOException((IOException) cause); - throw new IllegalArgumentException( - "Execution of invalid compilation units failed with exception", cause); - } catch (Exception e) { - throw new IllegalArgumentException( - "Execution of invalid compilation units failed with exception", e); - } - } - - /** - * Check if a JavaFileObject contains a main method. A main method is considered to be present if - * the file contains the literal string {@code "public static void main(String[] args)"}. - * - * @param file the file to check - * @return {@code true} if the file contains a main method, {@code false} otherwise - * @throws UncheckedIOException if an IOException occurs while reading the file - */ - private static boolean containsMainMethod(JavaFileObject file) { - try { - return file.getCharContent(true).toString() - .contains("public static void main(String[] args)"); - } catch (IOException e) { - // This really ought never to happen, since it's all in memory. - throw new UncheckedIOException("Failed to read JavaFileObject contents", e); - } - } - - /** - * Extracts the qualified Java class name from a JavaFileObject. - * - * @param file the JavaFileObject representing the Java source code - * @return the qualified class name - */ - private static String toQualifiedClassName(JavaFileObject file) { - // Get the name of the JavaFileObject - String fileName = file.getName(); - - // Remove directories or prefixes before the package - // Example: "/com/example/HelloWorld.java" - // becomes "com/example/HelloWorld.java" - if (fileName.startsWith("/")) { - fileName = fileName.substring(1); - } - - // Remove the ".java" extension - if (fileName.endsWith(".java")) { - fileName = fileName.substring(0, fileName.length() - ".java".length()); - } - - // Replace '/' with '.' to form package and class hierarchy - fileName = fileName.replace("/", "."); - - return fileName; - } - - private static final Pattern PACKAGE_DECLARATION_PATTERN = - Pattern.compile("^package\\s+(\\S+)\\s*;", Pattern.MULTILINE); - private static final Pattern CLASS_DECLARATION_PATTERN = - Pattern.compile("^public\\s+(?:class|interface)\\s+(\\S+)\\s*\\{", Pattern.MULTILINE); - - private static JavaFileObject prepareSourceFile(String sourceCode) { - final String packageName = PACKAGE_DECLARATION_PATTERN.matcher(sourceCode).results().findFirst() - .map(m -> m.group(1)).orElse(null); - - final String simpleClassName = CLASS_DECLARATION_PATTERN.matcher(sourceCode).results() - .findFirst().map(m -> m.group(1)).orElse(null); - if (simpleClassName == null) - throw new IllegalArgumentException("Failed to detect class name"); - - final String qualifiedClassName = - packageName != null ? packageName + "." + simpleClassName : simpleClassName; - - return JavaFileObjects.forSourceString(qualifiedClassName, sourceCode); - } }