MavenITExtension.java

package com.soebes.itf.jupiter.extension;

/*
 * 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
 *
 *  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.
 */

import com.soebes.itf.jupiter.maven.MavenCacheResult;
import com.soebes.itf.jupiter.maven.MavenExecutionResult;
import com.soebes.itf.jupiter.maven.MavenExecutionResult.ExecutionResult;
import com.soebes.itf.jupiter.maven.MavenLog;
import com.soebes.itf.jupiter.maven.MavenProjectResult;
import com.soebes.itf.jupiter.maven.ProjectHelper;
import org.apache.maven.model.Model;
import org.junit.jupiter.api.condition.OS;
import org.junit.jupiter.api.extension.BeforeEachCallback;
import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
import org.junit.jupiter.api.extension.ExtensionConfigurationException;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.junit.jupiter.api.extension.InvocationInterceptor;
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.api.extension.ParameterResolver;
import org.junit.jupiter.api.extension.ReflectiveInvocationContext;

import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;

import static com.soebes.itf.jupiter.extension.AnnotationHelper.findMavenProjectLocationAnnotation;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.goals;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.hasGoals;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.hasOptions;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.hasProfiles;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.hasSystemProperties;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.options;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.profiles;
import static com.soebes.itf.jupiter.extension.AnnotationHelper.systemProperties;
import static com.soebes.itf.jupiter.extension.MavenProjectSources.ResourceUsage.DEFAULT;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;

/**
 * @author Karl Heinz Marbaise
 */
class MavenITExtension implements BeforeEachCallback, ParameterResolver, BeforeTestExecutionCallback,
    InvocationInterceptor {

  /**
   * The command line options which are given is no annotation at all is defined.
   */
  private static final List<String> DEFAULT_COMMAND_LINE_OPTIONS = Arrays.asList(MavenCLIOptions.BATCH_MODE,
      MavenCLIOptions.SHOW_VERSION, MavenCLIOptions.ERRORS);

  @Override
  public void beforeEach(ExtensionContext context) throws Exception {
    Class<?> testClass = context.getTestClass()
        .orElseThrow(() -> new ExtensionConfigurationException("MavenITExtension is only supported for classes."));

    boolean resourcesIts = AnnotationHelper.findMavenProjectSourcesAnnotation(context)
        .map(s -> s.resourcesUsage().equals(DEFAULT))
        .orElse(true);

    //FIXME: Need to reconsider the maven-it directory?
    Path targetTestClassesDirectory = DirectoryHelper.getTargetDir().resolve("maven-it");
    String toFullyQualifiedPath = DirectoryHelper.toFullyQualifiedPath(testClass);

    Path mavenItTestCaseBaseDirectory = targetTestClassesDirectory.resolve(toFullyQualifiedPath);
    //TODO: What happens if the directory has been created by a previous run?
    // should we delete that structure here? Maybe we should make this configurable.
    Files.createDirectories(mavenItTestCaseBaseDirectory);

    StorageHelper storageHelper = new StorageHelper(context);
    storageHelper.save(targetTestClassesDirectory, mavenItTestCaseBaseDirectory, DirectoryHelper.getTargetDir());

    Optional<Class<?>> mavenProject = AnnotationHelper.findMavenProjectAnnotation(context);

    DirectoryResolverResult directoryResolverResult = new DirectoryResolverResult(context);
    Path integrationTestCaseDirectory = directoryResolverResult.getIntegrationTestCaseDirectory();

    // FIXME: Model should be done right.
    MavenProjectResult mavenProjectResult = new MavenProjectResult(directoryResolverResult.getTargetDirectory(), directoryResolverResult.getProjectDirectory(), directoryResolverResult.getIntegrationTestCaseDirectory(), new Model());
    storageHelper.put(ParameterType.ProjectResult + context.getUniqueId(), mavenProjectResult);

    Files.createDirectories(integrationTestCaseDirectory);

    //TODO: Reconsider deleting the local cache .m2/repository with each run yes/no
    // Define default behaviour => Remove it.
    // Make it configurable. How? Think about?
    if (mavenProject.isPresent()) {
      if (!Files.exists(directoryResolverResult.getProjectDirectory())) {
        Files.createDirectories(directoryResolverResult.getProjectDirectory());
        Files.createDirectories(directoryResolverResult.getCacheDirectory());

        PathUtils.copyDirectoryRecursively(directoryResolverResult.getSourceMavenProject(),
            directoryResolverResult.getProjectDirectory());

        PathUtils.copyDirectoryRecursively(directoryResolverResult.getTargetItfRepoDirectory(),
            directoryResolverResult.getCacheDirectory());
      }
    } else {
      PathUtils.deleteRecursively(directoryResolverResult.getProjectDirectory());
      Files.createDirectories(directoryResolverResult.getProjectDirectory());
      Files.createDirectories(directoryResolverResult.getCacheDirectory());

      if (!resourcesIts) {
        return;
      }

      PathUtils.copyDirectoryRecursively(directoryResolverResult.getSourceMavenProject(),
          directoryResolverResult.getProjectDirectory());
      PathUtils.copyDirectoryRecursively(directoryResolverResult.getTargetItfRepoDirectory(),
          directoryResolverResult.getCacheDirectory());
    }

  }

  @Override
  public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
    return Stream.of(ParameterType.values())
        .anyMatch(parameterType -> parameterType.getKlass() == parameterContext.getParameter().getType());
  }

  @Override
  public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {

    StorageHelper sh = new StorageHelper(extensionContext);

    ParameterType parameterType = Stream.of(ParameterType.values())
        .filter(s -> s.getKlass() == parameterContext.getParameter().getType())
        .findFirst()
        .orElseThrow(() -> new IllegalStateException("Unknown parameter type"));

    return sh.get(parameterType + extensionContext.getUniqueId(), parameterType.getKlass());
  }

  @Override
  public void interceptBeforeEachMethod(Invocation<Void> invocation,
                                        ReflectiveInvocationContext<Method> invocationContext, ExtensionContext extensionContext) throws Throwable {
    invocation.proceed();
  }


  /**
   * @return The content of the {@code PATH} environment variable.
   * @implNote The usage of System.getenv("PATH") triggers the java:S5304. In this case we don't transfer sensitive information.
   * In this case we suppress that SonarQube warning.
   */
  @SuppressWarnings("java:S5304")
  private Optional<String> getSystemPATH() {
    return Optional.ofNullable(System.getenv("PATH"));
  }

  @Override
  public void beforeTestExecution(ExtensionContext context)
      throws IOException, InterruptedException {

    Method methodName = context.getTestMethod().orElseThrow(() -> new IllegalStateException("No method given"));

    String prefix = "mvn";
    Optional<Class<?>> mavenProject = AnnotationHelper.findMavenProjectAnnotation(context);
    //TODO: In cases where we have MavenProject it might be better to have
    // different directories (which would be more concise with the other assumptions) with directory idea instead
    // of prefixed files.
    if (mavenProject.isPresent()) {
      prefix = methodName.getName() + "-mvn";
    }

    DirectoryResolverResult directoryResolverResult = new DirectoryResolverResult(context);
    Path integrationTestCaseDirectory = directoryResolverResult.getIntegrationTestCaseDirectory();
    Files.createDirectories(integrationTestCaseDirectory);

    //Copy ".predefined-repo" into ".m2/repository"
    Optional<Path> predefinedRepository = directoryResolverResult.getPredefinedRepository();
    if (predefinedRepository.isPresent()) {
      PathUtils.copyDirectoryRecursively(predefinedRepository.get(),
          directoryResolverResult.getCacheDirectory());
    } else {
      boolean annotationPresent = methodName.isAnnotationPresent(MavenPredefinedRepository.class);
      if (annotationPresent) {
        MavenPredefinedRepository annotation = methodName.getAnnotation(MavenPredefinedRepository.class);
        Path predefinedRepoPath = directoryResolverResult.getSourceMavenProject().resolve(annotation.value());
        PathUtils.copyDirectoryRecursively(predefinedRepoPath,
            directoryResolverResult.getCacheDirectory());
      }
    }

    Optional<Path> mvnLocation = new MavenLocator(FileSystems.getDefault(), getSystemPATH(), OS.WINDOWS.isCurrentOs()).findMvn();
    if (!mvnLocation.isPresent()) {
      throw new IllegalStateException("We could not find the maven executable `mvn` somewhere");
    }

    Path projectWorkingDirectory = directoryResolverResult.getProjectDirectory();
    Optional<MavenProjectLocation> mavenProjectLocationContext = findMavenProjectLocationAnnotation(context);
    if (mavenProjectLocationContext.isPresent()) {
      String mavenProjectLocation = mavenProjectLocationContext.get().value();
      if (mavenProjectLocation.isEmpty()) {
        throw new IllegalStateException("You have to define a location in your MavenProjectLocation annotation");
      }
      projectWorkingDirectory = directoryResolverResult.getProjectDirectory().resolve(mavenProjectLocation);
    }

    ApplicationExecutor mavenExecutor = new ApplicationExecutor(projectWorkingDirectory,
        integrationTestCaseDirectory, mvnLocation.get(), Collections.emptyList(), prefix);

    List<String> executionArguments = new ArrayList<>();

    //TODO: Reconsider about the default options which are being defined here? Documented? users guide?
    List<String> defaultArguments = Arrays.asList(
        "-Dmaven.repo.local=" + directoryResolverResult.getCacheDirectory().toString());
    executionArguments.addAll(defaultArguments);

    if (hasProfiles(context)) {
      String collect = profiles(context).stream().collect(joining(",", "-P", ""));
      executionArguments.add(collect);
    }

    if (hasSystemProperties(context)) {
      List<String> collect = systemProperties(context)
          .stream()
          .map(s -> s.content().isEmpty() ? "-D" + s.value() : "-D" + s.value() + "=" + s.content())
          .collect(toList());
      executionArguments.addAll(collect);
    }

    if (hasOptions(context)) {
      executionArguments.addAll(options(context));
    } else {
      // If no option is defined at all the following are the defaults.
      executionArguments.addAll(DEFAULT_COMMAND_LINE_OPTIONS);
    }


    if (hasGoals(context)) {
      List<String> resultingGoals = goals(context);
      Map<String, String> keyValues = pomEntries(directoryResolverResult);

      List<String> filteredGoals = new PropertiesFilter(keyValues, resultingGoals).filter();
      executionArguments.addAll(filteredGoals);
    } else {
      //TODO: This is the default goal which will be executed if no `@MavenGoal` at all annotation is defined.
      executionArguments.add("package");
    }


    Process start = mavenExecutor.start(executionArguments);

    int processCompletableFuture = start.waitFor();

    ExecutionResult executionResult = ExecutionResult.Successful;
    if (processCompletableFuture != 0) {
      executionResult = ExecutionResult.Failure;
    }

    MavenLog log = new MavenLog(mavenExecutor.getStdout(), mavenExecutor.getStdErr());
    MavenCacheResult mavenCacheResult = new MavenCacheResult(directoryResolverResult.getCacheDirectory());

    Model model = ProjectHelper.readProject(projectWorkingDirectory.resolve("pom.xml"));

    MavenProjectResult mavenProjectResult = new MavenProjectResult(directoryResolverResult.getIntegrationTestCaseDirectory(),
        directoryResolverResult.getProjectDirectory(), directoryResolverResult.getCacheDirectory(), model);

    MavenExecutionResult result = new MavenExecutionResult(executionResult, processCompletableFuture, log,
        mavenProjectResult, mavenCacheResult);

    new StorageHelper(context).save(result, log, mavenCacheResult, mavenProjectResult);
  }

  private Map<String, String> pomEntries(DirectoryResolverResult directoryResolverResult) {
    //FIXME: Need to introduce better directory names
    Path mavenBaseDirectory = directoryResolverResult.getTargetDirectory().getParent();
    Path pomFile = mavenBaseDirectory.resolve("pom.xml");

    ModelReader modelReader = new ModelReader(ProjectHelper.readProject(pomFile));
    Map<String, String> keyValues = new HashMap<>();
    //The following three elements are read from the original pom file.
    keyValues.put("project.groupId", modelReader.getGroupId());
    keyValues.put("project.artifactId", modelReader.getArtifactId());
    keyValues.put("project.version", modelReader.getVersion());
    return keyValues;
  }

}