content: /*
   *  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.taskdefs.optional.junitlauncher.confined.TestDefinition.ForkedRepresentation;
  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.Optional;
  import java.util.Properties;
  import java.util.StringTokenizer;
  import java.util.concurrent.TimeoutException;
  import java.util.stream.Collectors;
  
  import static java.util.concurrent.TimeUnit.MILLISECONDS;
  import static java.util.concurrent.TimeUnit.NANOSECONDS;
  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 final List<String> includeTags = new ArrayList<>();
      private final 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;
              }
              final ForkDefinition forkDefinition = test.getForkDefinition();
              if (forkDefinition == null) {
                  launchViaReflection(new InVMLaunch(Collections.singletonList(test)));
              } else {
                  final List<ForkedRepresentation> forkedReps = test.toForkedRepresentations();
                  final long timeout = forkDefinition.getTimeout(); // in millis
                  // compute the "end time" (if any)
                  final Optional<Long> deadlineNanos = timeout == -1
                          ? Optional.empty() // no timeout
                          : Optional.of(System.nanoTime() + MILLISECONDS.toNanos(timeout));
                  for (final ForkedRepresentation forkedRep : forkedReps) {
                      forkTest(test, forkDefinition, forkedRep, deadlineNanos);
                  }
              }
          }
      }
  
      /**
       * 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, final ForkDefinition forkDefinition,
                            final ForkedRepresentation forkedRepresentation,
                            final Optional<Long> deadlineNanos) {
          // create launch command
          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
                  forkedRepresentation.write(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, deadlineNanos);
          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: {
                  // 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()) {
                          throw new BuildException(new TimeoutException("Forked test(s) timed out"));
                      } else {
                          log("Timeout occurred. Please note the time in the report does not reflect the time until the timeout.");
                      }
                  }
              }
          }
      }
  
      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 Optional<Long> deadlineNanos) {
          final LogOutputStream outStream = new LogOutputStream(this, Project.MSG_INFO);
          final LogOutputStream errStream = new LogOutputStream(this, Project.MSG_WARN);
          final ExecuteWatchdog watchdog;
          if (deadlineNanos.isPresent()) {
              final long remainingNanos = deadlineNanos.get() - System.nanoTime();
              if (remainingNanos <= 0) {
                  // already timed out
                  return Constants.FORK_EXIT_CODE_TIMED_OUT;
              }
              final long timeoutInMillis = NANOSECONDS.toMillis(remainingNanos);
              if (timeoutInMillis == 0) {
                  // already timed out
                  return Constants.FORK_EXIT_CODE_TIMED_OUT;
              }
              watchdog = createExecuteWatchdog(timeoutInMillis);
          } else {
              watchdog = null; // no timeout specified
          }
          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;
      }
  
      protected ExecuteWatchdog createExecuteWatchdog(long timeout) {
          return new ExecuteWatchdog(timeout);
      }
  
      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);
          }
      }
  }
  