| /* |
| * Licensed to the Apache Software Foundation (ASF) under one or more |
| * contributor license agreements. See the NOTICE file distributed with |
| * this work for additional information regarding copyright ownership. |
| * The ASF licenses this file to You 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 |
| * |
| * https://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.apache.tools.ant.taskdefs.optional.junitlauncher.confined; |
| |
| import org.apache.tools.ant.AntClassLoader; |
| import org.apache.tools.ant.BuildException; |
| import org.apache.tools.ant.Project; |
| import org.apache.tools.ant.Task; |
| import org.apache.tools.ant.taskdefs.Execute; |
| import org.apache.tools.ant.taskdefs.ExecuteWatchdog; |
| import org.apache.tools.ant.taskdefs.LogOutputStream; |
| import org.apache.tools.ant.taskdefs.PumpStreamHandler; |
| import org.apache.tools.ant.types.CommandlineJava; |
| import org.apache.tools.ant.types.Environment; |
| import org.apache.tools.ant.types.Path; |
| import org.apache.tools.ant.util.FileUtils; |
| |
| import javax.xml.stream.XMLOutputFactory; |
| import javax.xml.stream.XMLStreamWriter; |
| import java.io.IOException; |
| import java.io.OutputStream; |
| import java.nio.charset.StandardCharsets; |
| import java.nio.file.Files; |
| import java.nio.file.Paths; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.Hashtable; |
| import java.util.List; |
| import java.util.Properties; |
| import java.util.StringTokenizer; |
| import java.util.concurrent.TimeoutException; |
| import java.util.stream.Collectors; |
| |
| import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_EXCLUDE_TAGS; |
| import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_HALT_ON_FAILURE; |
| import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_INCLUDE_TAGS; |
| import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ATTR_PRINT_SUMMARY; |
| import static org.apache.tools.ant.taskdefs.optional.junitlauncher.confined.Constants.LD_XML_ELM_LAUNCH_DEF; |
| |
| |
| /** |
| * An Ant {@link Task} responsible for launching the JUnit platform for running tests. |
| * This requires a minimum of JUnit 5, since that's the version in which the JUnit platform launcher |
| * APIs were introduced. |
| * <p> |
| * This task in itself doesn't run the JUnit tests, instead the sole responsibility of |
| * this task is to setup the JUnit platform launcher, build requests, launch those requests and then parse the |
| * result of the execution to present in a way that's been configured on this Ant task. |
| * </p> |
| * <p> |
| * Furthermore, this task allows users control over which classes to select for passing on to the JUnit 5 |
| * platform for test execution. It however, is solely the JUnit 5 platform, backed by test engines that |
| * decide and execute the tests. |
| * |
| * @see <a href="https://junit.org/junit5/">JUnit 5 documentation</a> |
| */ |
| public class JUnitLauncherTask extends Task { |
| |
| private static final String LAUNCHER_SUPPORT_CLASS_NAME = "org.apache.tools.ant.taskdefs.optional.junitlauncher.LauncherSupport"; |
| private static final String IN_VM_TEST_EXECUTION_CONTEXT_CLASS_NAME = "org.apache.tools.ant.taskdefs.optional.junitlauncher.InVMExecution"; |
| private static final String TEST_EXECUTION_CONTEXT_CLASS_NAME = "org.apache.tools.ant.taskdefs.optional.junitlauncher.TestExecutionContext"; |
| |
| private Path classPath; |
| private boolean haltOnFailure; |
| private String failureProperty; |
| private boolean printSummary; |
| private final List<TestDefinition> tests = new ArrayList<>(); |
| private final List<ListenerDefinition> listeners = new ArrayList<>(); |
| private List<String> includeTags = new ArrayList<>(); |
| private List<String> excludeTags = new ArrayList<>(); |
| |
| public JUnitLauncherTask() { |
| } |
| |
| @Override |
| public void execute() throws BuildException { |
| if (this.tests.isEmpty()) { |
| return; |
| } |
| final Project project = getProject(); |
| for (final TestDefinition test : this.tests) { |
| if (!test.shouldRun(project)) { |
| log("Excluding test " + test + " since it's considered not to run " + |
| "in context of project " + project, Project.MSG_DEBUG); |
| continue; |
| } |
| if (test.getForkDefinition() != null) { |
| forkTest(test); |
| } else { |
| launchViaReflection(new InVMLaunch(Collections.singletonList(test))); |
| } |
| } |
| } |
| |
| /** |
| * Adds the {@link Path} to the classpath which will be used for execution of the tests |
| * |
| * @param path The classpath |
| */ |
| public void addConfiguredClassPath(final Path path) { |
| if (this.classPath == null) { |
| // create a "wrapper" path which can hold on to multiple |
| // paths that get passed to this method (if at all the task in the build is |
| // configured with multiple classpaht elements) |
| this.classPath = new Path(getProject()); |
| } |
| this.classPath.add(path); |
| } |
| |
| /** |
| * Adds a {@link SingleTestClass} that will be passed on to the underlying JUnit platform |
| * for possible execution of the test |
| * |
| * @param test The test |
| */ |
| public void addConfiguredTest(final SingleTestClass test) { |
| this.preConfigure(test); |
| this.tests.add(test); |
| } |
| |
| /** |
| * Adds {@link TestClasses} that will be passed on to the underlying JUnit platform for |
| * possible execution of the tests |
| * |
| * @param testClasses The test classes |
| */ |
| public void addConfiguredTestClasses(final TestClasses testClasses) { |
| this.preConfigure(testClasses); |
| this.tests.add(testClasses); |
| } |
| |
| /** |
| * Adds a {@link ListenerDefinition listener} which will be enrolled for listening to test |
| * execution events |
| * |
| * @param listener The listener |
| */ |
| public void addConfiguredListener(final ListenerDefinition listener) { |
| this.listeners.add(listener); |
| } |
| |
| public void setHaltonfailure(final boolean haltonfailure) { |
| this.haltOnFailure = haltonfailure; |
| } |
| |
| public void setFailureProperty(final String failureProperty) { |
| this.failureProperty = failureProperty; |
| } |
| |
| public void setPrintSummary(final boolean printSummary) { |
| this.printSummary = printSummary; |
| } |
| |
| /** |
| * Tags to include. Will trim each tag. |
| * |
| * @param includes comma separated list of tags to include while running the tests. |
| * @since Ant 1.10.7 |
| */ |
| public void setIncludeTags(final String includes) { |
| final StringTokenizer tokens = new StringTokenizer(includes, ","); |
| while (tokens.hasMoreTokens()) { |
| includeTags.add(tokens.nextToken().trim()); |
| } |
| } |
| |
| /** |
| * Tags to exclude. Will trim each tag. |
| * |
| * @param excludes comma separated list of tags to exclude while running the tests. |
| * @since Ant 1.10.7 |
| */ |
| public void setExcludeTags(final String excludes) { |
| final StringTokenizer tokens = new StringTokenizer(excludes, ","); |
| while (tokens.hasMoreTokens()) { |
| excludeTags.add(tokens.nextToken().trim()); |
| } |
| } |
| |
| private void preConfigure(final TestDefinition test) { |
| if (test.getHaltOnFailure() == null) { |
| test.setHaltOnFailure(this.haltOnFailure); |
| } |
| if (test.getFailureProperty() == null) { |
| test.setFailureProperty(this.failureProperty); |
| } |
| } |
| |
| private void launchViaReflection(final InVMLaunch launchDefinition) { |
| final ClassLoader cl = launchDefinition.getClassLoader(); |
| // instantiate a new TestExecutionContext instance using the launch definition's classloader |
| final Class<?> testExecutionCtxClass; |
| final Object testExecutionCtx; |
| try { |
| testExecutionCtxClass = Class.forName(TEST_EXECUTION_CONTEXT_CLASS_NAME, false, cl); |
| final Class<?> klass = Class.forName(IN_VM_TEST_EXECUTION_CONTEXT_CLASS_NAME, false, cl); |
| testExecutionCtx = klass.getConstructor(JUnitLauncherTask.class).newInstance(this); |
| } catch (Exception e) { |
| throw new BuildException("Failed to create a test execution context for in-vm tests", e); |
| } |
| // instantiate a new LauncherSupport instance using the launch definition's ClassLoader |
| try { |
| final Class<?> klass = Class.forName(LAUNCHER_SUPPORT_CLASS_NAME, false, cl); |
| final Object launcherSupport = klass.getConstructor(LaunchDefinition.class, testExecutionCtxClass) |
| .newInstance(launchDefinition, testExecutionCtx); |
| klass.getMethod("launch").invoke(launcherSupport); |
| } catch (Exception e) { |
| throw new BuildException("Failed to launch in-vm tests", e); |
| } |
| } |
| |
| private java.nio.file.Path dumpProjectProperties() throws IOException { |
| final java.nio.file.Path propsPath = FileUtils.getFileUtils() |
| .createTempFile(getProject(), null, "properties", null, true, true) |
| .toPath(); |
| final Hashtable<String, Object> props = this.getProject().getProperties(); |
| final Properties projProperties = new Properties(); |
| projProperties.putAll(props); |
| try (final OutputStream os = Files.newOutputStream(propsPath)) { |
| // TODO: Is it always UTF-8? |
| projProperties.store(os, StandardCharsets.UTF_8.name()); |
| } |
| return propsPath; |
| } |
| |
| private void forkTest(final TestDefinition test) { |
| // create launch command |
| final ForkDefinition forkDefinition = test.getForkDefinition(); |
| final CommandlineJava commandlineJava = forkDefinition.generateCommandLine(this); |
| if (this.classPath != null) { |
| commandlineJava.createClasspath(getProject()).createPath().append(this.classPath); |
| } |
| final java.nio.file.Path projectPropsPath; |
| try { |
| projectPropsPath = dumpProjectProperties(); |
| } catch (IOException e) { |
| throw new BuildException("Could not create the necessary properties file while forking a process" + |
| " for a test", e); |
| } |
| // --properties <path-to-properties-file> |
| commandlineJava.createArgument().setValue(Constants.ARG_PROPERTIES); |
| commandlineJava.createArgument().setValue(projectPropsPath.toAbsolutePath().toString()); |
| |
| final java.nio.file.Path launchDefXmlPath = newLaunchDefinitionXml(); |
| try (final OutputStream os = Files.newOutputStream(launchDefXmlPath)) { |
| final XMLStreamWriter writer = XMLOutputFactory.newFactory().createXMLStreamWriter(os, "UTF-8"); |
| try { |
| writer.writeStartDocument(); |
| writer.writeStartElement(LD_XML_ELM_LAUNCH_DEF); |
| if (this.printSummary) { |
| writer.writeAttribute(LD_XML_ATTR_PRINT_SUMMARY, "true"); |
| } |
| if (this.haltOnFailure) { |
| writer.writeAttribute(LD_XML_ATTR_HALT_ON_FAILURE, "true"); |
| } |
| if (this.includeTags.size() > 0) { |
| writer.writeAttribute(LD_XML_ATTR_INCLUDE_TAGS, commaSeparatedListElements(includeTags)); |
| } |
| if (this.excludeTags.size() > 0) { |
| writer.writeAttribute(LD_XML_ATTR_EXCLUDE_TAGS, commaSeparatedListElements(excludeTags)); |
| } |
| // task level listeners |
| for (final ListenerDefinition listenerDef : this.listeners) { |
| if (!listenerDef.shouldUse(getProject())) { |
| continue; |
| } |
| // construct the listener definition argument |
| listenerDef.toForkedRepresentation(writer); |
| } |
| // test definition as XML |
| test.toForkedRepresentation(this, writer); |
| writer.writeEndElement(); |
| writer.writeEndDocument(); |
| } finally { |
| writer.close(); |
| } |
| } catch (Exception e) { |
| throw new BuildException("Failed to construct command line for test", e); |
| } |
| // --launch-definition <xml-file-path> |
| commandlineJava.createArgument().setValue(Constants.ARG_LAUNCH_DEFINITION); |
| commandlineJava.createArgument().setValue(launchDefXmlPath.toAbsolutePath().toString()); |
| |
| // launch the process and wait for process to complete |
| final int exitCode = executeForkedTest(forkDefinition, commandlineJava); |
| switch (exitCode) { |
| case Constants.FORK_EXIT_CODE_SUCCESS: { |
| // success |
| break; |
| } |
| case Constants.FORK_EXIT_CODE_EXCEPTION: { |
| // process failed with some exception |
| throw new BuildException("Forked test(s) failed with an exception"); |
| } |
| case Constants.FORK_EXIT_CODE_TESTS_FAILED: { |
| // test has failure(s) |
| try { |
| if (test.getFailureProperty() != null) { |
| // if there are test failures and the test is configured to set a property in case |
| // of failure, then set the property to true |
| this.getProject().setNewProperty(test.getFailureProperty(), "true"); |
| } |
| } finally { |
| if (test.isHaltOnFailure()) { |
| // if the test is configured to halt on test failures, throw a build error |
| final String errorMessage; |
| if (test instanceof NamedTest) { |
| errorMessage = "Test " + ((NamedTest) test).getName() + " has failure(s)"; |
| } else { |
| errorMessage = "Some test(s) have failure(s)"; |
| } |
| throw new BuildException(errorMessage); |
| } |
| } |
| break; |
| } |
| case Constants.FORK_EXIT_CODE_TIMED_OUT: { |
| throw new BuildException(new TimeoutException("Forked test(s) timed out")); |
| } |
| } |
| } |
| |
| private static String commaSeparatedListElements(final List<String> stringList) { |
| return stringList.stream() |
| .map(Object::toString) |
| .collect(Collectors.joining(", ")); |
| } |
| |
| private int executeForkedTest(final ForkDefinition forkDefinition, final CommandlineJava commandlineJava) { |
| final LogOutputStream outStream = new LogOutputStream(this, Project.MSG_INFO); |
| final LogOutputStream errStream = new LogOutputStream(this, Project.MSG_WARN); |
| final ExecuteWatchdog watchdog = forkDefinition.getTimeout() > 0 ? new ExecuteWatchdog(forkDefinition.getTimeout()) : null; |
| final Execute execute = new Execute(new PumpStreamHandler(outStream, errStream), watchdog); |
| execute.setCommandline(commandlineJava.getCommandline()); |
| execute.setAntRun(getProject()); |
| if (forkDefinition.getDir() != null) { |
| execute.setWorkingDirectory(Paths.get(forkDefinition.getDir()).toFile()); |
| } |
| final Environment env = forkDefinition.getEnv(); |
| if (env != null && env.getVariables() != null) { |
| execute.setEnvironment(env.getVariables()); |
| } |
| log(commandlineJava.describeCommand(), Project.MSG_VERBOSE); |
| int exitCode; |
| try { |
| exitCode = execute.execute(); |
| } catch (IOException e) { |
| throw new BuildException("Process fork failed", e, getLocation()); |
| } |
| return (watchdog != null && watchdog.killedProcess()) ? Constants.FORK_EXIT_CODE_TIMED_OUT : exitCode; |
| } |
| |
| private java.nio.file.Path newLaunchDefinitionXml() { |
| return FileUtils.getFileUtils() |
| .createTempFile(getProject(), null, ".xml", null, true, true) |
| .toPath(); |
| } |
| |
| private final class InVMLaunch implements LaunchDefinition { |
| |
| private final List<TestDefinition> inVMTests; |
| private final ClassLoader executionCL; |
| |
| private InVMLaunch(final List<TestDefinition> inVMTests) { |
| this.inVMTests = inVMTests; |
| this.executionCL = createInVMExecutionClassLoader(); |
| } |
| |
| @Override |
| public List<TestDefinition> getTests() { |
| return this.inVMTests; |
| } |
| |
| @Override |
| public List<ListenerDefinition> getListeners() { |
| return listeners; |
| } |
| |
| @Override |
| public boolean isPrintSummary() { |
| return printSummary; |
| } |
| |
| @Override |
| public boolean isHaltOnFailure() { |
| return haltOnFailure; |
| } |
| |
| @Override |
| public List<String> getIncludeTags() { |
| return includeTags; |
| } |
| |
| @Override |
| public List<String> getExcludeTags() { |
| return excludeTags; |
| } |
| |
| @Override |
| public ClassLoader getClassLoader() { |
| return this.executionCL; |
| } |
| |
| private ClassLoader createInVMExecutionClassLoader() { |
| final Path taskConfiguredClassPath = JUnitLauncherTask.this.classPath; |
| if (taskConfiguredClassPath == null) { |
| // no specific classpath configured for the task, so use the classloader |
| // of this task |
| return JUnitLauncherTask.class.getClassLoader(); |
| } |
| // there's a classpath configured for the task. |
| // we first check if the Ant runtime classpath has JUnit platform classes. |
| // - if it does, then we use the Ant runtime classpath plus the task's configured classpath |
| // with the traditional parent first loading. |
| // - else (i.e. Ant runtime classpath doesn't have JUnit platform classes), then we |
| // expect/assume the task's configured classpath to have the JUnit platform classes and we |
| // then create a "overriding" classloader which prefers certain resources (specifically the classes |
| // from org.apache.tools.ant.taskdefs.optional.junitlauncher package), from the task's |
| // classpath, even if the Ant's runtime classpath has those resources. |
| if (JUnitLauncherClassPathUtil.hasJUnitPlatformResources(JUnitLauncherTask.class.getClassLoader())) { |
| return new AntClassLoader(JUnitLauncherTask.class.getClassLoader(), getProject(), taskConfiguredClassPath, true); |
| } |
| final Path cp = new Path(getProject()); |
| cp.add(taskConfiguredClassPath); |
| // add the Ant runtime resources to this path |
| JUnitLauncherClassPathUtil.addLauncherSupportResourceLocation(cp, JUnitLauncherTask.class.getClassLoader()); |
| return new TaskConfiguredPathClassLoader(JUnitLauncherTask.class.getClassLoader(), cp, getProject()); |
| } |
| } |
| |
| /** |
| * A {@link ClassLoader}, very similar to the {@link org.apache.tools.ant.util.SplitClassLoader}, |
| * which uses the {@link #TaskConfiguredPathClassLoader(ClassLoader, Path, Project) configured Path} |
| * to load a class, if the class belongs to the {@code org.apache.tools.ant.taskdefs.optional.junitlauncher} |
| * package. |
| * <p> |
| * While looking for classes belonging to the {@code org.apache.tools.ant.taskdefs.optional.junitlauncher} |
| * package, this classloader completely ignores Ant runtime classpath, even if that classpath has |
| * those classes. This allows the users of this classloader to use a custom location and thus more control over |
| * where these classes reside, when running the {@code junitlauncher} task |
| */ |
| private final class TaskConfiguredPathClassLoader extends AntClassLoader { |
| |
| /** |
| * @param parent ClassLoader |
| * @param path Path |
| * @param project Project |
| */ |
| private TaskConfiguredPathClassLoader(ClassLoader parent, Path path, Project project) { |
| super(parent, project, path, true); |
| } |
| |
| // forceLoadClass is not convenient here since it would not |
| // properly deal with inner classes of these classes. |
| @Override |
| protected synchronized Class<?> loadClass(String classname, boolean resolve) |
| throws ClassNotFoundException { |
| Class<?> theClass = findLoadedClass(classname); |
| if (theClass != null) { |
| return theClass; |
| } |
| final String packageName = classname.contains(".") ? classname.substring(0, classname.lastIndexOf('.')) |
| : ""; |
| if (packageName.equals("org.apache.tools.ant.taskdefs.optional.junitlauncher")) { |
| theClass = findClass(classname); |
| if (resolve) { |
| resolveClass(theClass); |
| } |
| return theClass; |
| } |
| return super.loadClass(classname, resolve); |
| } |
| } |
| } |