1 /*
   2  * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
   3  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
   4  *
   5  * This code is free software; you can redistribute it and/or modify it
   6  * under the terms of the GNU General Public License version 2 only, as
   7  * published by the Free Software Foundation.
   8  *
   9  * This code is distributed in the hope that it will be useful, but WITHOUT
  10  * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
  11  * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
  12  * version 2 for more details (a copy is included in the LICENSE file that
  13  * accompanied this code).
  14  *
  15  * You should have received a copy of the GNU General Public License version
  16  * 2 along with this work; if not, write to the Free Software Foundation,
  17  * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
  18  *
  19  * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
  20  * or visit www.oracle.com if you need additional information or have any
  21  * questions.
  22  */
  23 
  24 package jdk.jpackage.test;
  25 
  26 import java.lang.reflect.Array;
  27 import java.lang.reflect.InvocationTargetException;
  28 import java.lang.reflect.Method;
  29 import java.lang.reflect.Modifier;
  30 import java.util.*;
  31 import java.util.concurrent.atomic.AtomicInteger;
  32 import java.util.function.Consumer;
  33 import java.util.function.Function;
  34 import java.util.function.UnaryOperator;
  35 import java.util.stream.Collectors;
  36 import java.util.stream.Stream;
  37 import jdk.jpackage.test.Annotations.AfterEach;
  38 import jdk.jpackage.test.Annotations.BeforeEach;
  39 import jdk.jpackage.test.Annotations.Parameter;
  40 import jdk.jpackage.test.Annotations.ParameterGroup;
  41 import jdk.jpackage.test.Annotations.Parameters;
  42 import jdk.jpackage.test.Annotations.Test;
  43 import jdk.jpackage.test.Functional.ThrowingConsumer;
  44 import jdk.jpackage.test.Functional.ThrowingFunction;
  45 
  46 final class TestBuilder implements AutoCloseable {
  47 
  48     @Override
  49     public void close() throws Exception {
  50         flushTestGroup();
  51     }
  52 
  53     TestBuilder(Consumer<TestInstance> testConsumer) {
  54         argProcessors = Map.of(
  55                 CMDLINE_ARG_PREFIX + "after-run",
  56                 arg -> getJavaMethodsFromArg(arg).map(
  57                         this::wrap).forEachOrdered(afterActions::add),
  58 
  59                 CMDLINE_ARG_PREFIX + "before-run",
  60                 arg -> getJavaMethodsFromArg(arg).map(
  61                         this::wrap).forEachOrdered(beforeActions::add),
  62 
  63                 CMDLINE_ARG_PREFIX + "run",
  64                 arg -> addTestGroup(getJavaMethodsFromArg(arg).map(
  65                         ThrowingFunction.toFunction(
  66                                 TestBuilder::toMethodCalls)).flatMap(s -> s).collect(
  67                         Collectors.toList())),
  68 
  69                 CMDLINE_ARG_PREFIX + "exclude",
  70                 arg -> (excludedTests = Optional.ofNullable(
  71                         excludedTests).orElseGet(() -> new HashSet<String>())).add(arg),
  72 
  73                 CMDLINE_ARG_PREFIX + "include",
  74                 arg -> (includedTests = Optional.ofNullable(
  75                         includedTests).orElseGet(() -> new HashSet<String>())).add(arg),
  76 
  77                 CMDLINE_ARG_PREFIX + "space-subst",
  78                 arg -> spaceSubstitute = arg,
  79 
  80                 CMDLINE_ARG_PREFIX + "group",
  81                 arg -> flushTestGroup(),
  82 
  83                 CMDLINE_ARG_PREFIX + "dry-run",
  84                 arg -> dryRun = true
  85         );
  86         this.testConsumer = testConsumer;
  87         clear();
  88     }
  89 
  90     void processCmdLineArg(String arg) throws Throwable {
  91         int separatorIdx = arg.indexOf('=');
  92         final String argName;
  93         final String argValue;
  94         if (separatorIdx != -1) {
  95             argName = arg.substring(0, separatorIdx);
  96             argValue = arg.substring(separatorIdx + 1);
  97         } else {
  98             argName = arg;
  99             argValue = null;
 100         }
 101         try {
 102             ThrowingConsumer<String> argProcessor = argProcessors.get(argName);
 103             if (argProcessor == null) {
 104                 throw new ParseException("Unrecognized");
 105             }
 106             argProcessor.accept(argValue);
 107         } catch (ParseException ex) {
 108             ex.setContext(arg);
 109             throw ex;
 110         }
 111     }
 112 
 113     private void addTestGroup(List<MethodCall> newTestGroup) {
 114         if (testGroup != null) {
 115             testGroup.addAll(newTestGroup);
 116         } else {
 117             testGroup = newTestGroup;
 118         }
 119     }
 120 
 121     private static Stream<MethodCall> filterTests(Stream<MethodCall> tests,
 122             Set<String> filters, UnaryOperator<Boolean> pred, String logMsg) {
 123         if (filters == null) {
 124             return tests;
 125         }
 126 
 127         // Log all matches before returning from the function
 128         return tests.filter(test -> {
 129             String testDescription = test.createDescription().testFullName();
 130             boolean match = filters.stream().anyMatch(testDescription::contains);
 131             if (match) {
 132                 trace(String.format(logMsg + ": %s", testDescription));
 133             }
 134             return pred.apply(match);
 135         }).collect(Collectors.toList()).stream();
 136     }
 137 
 138     private Stream<MethodCall> filterTestGroup() {
 139         Objects.requireNonNull(testGroup);
 140 
 141         UnaryOperator<Set<String>> restoreSpaces = filters -> {
 142             if (spaceSubstitute == null || filters == null) {
 143                 return filters;
 144             }
 145             return filters.stream().map(
 146                     filter -> filter.replace(spaceSubstitute, " ")).collect(
 147                             Collectors.toSet());
 148         };
 149 
 150         if (includedTests != null) {
 151             return filterTests(testGroup.stream(), restoreSpaces.apply(
 152                     includedTests), x -> x, "Include");
 153         }
 154 
 155         return filterTests(testGroup.stream(),
 156                 restoreSpaces.apply(excludedTests), x -> !x, "Exclude");
 157     }
 158 
 159     private void flushTestGroup() {
 160         if (testGroup != null) {
 161             filterTestGroup().forEach(this::createTestInstance);
 162             clear();
 163         }
 164     }
 165 
 166     private void createTestInstance(MethodCall testBody) {
 167         final List<ThrowingConsumer> curBeforeActions;
 168         final List<ThrowingConsumer> curAfterActions;
 169 
 170         Method testMethod = testBody.getMethod();
 171         if (Stream.of(BeforeEach.class, AfterEach.class).anyMatch(
 172                 testMethod::isAnnotationPresent)) {
 173             curBeforeActions = beforeActions;
 174             curAfterActions = afterActions;
 175         } else {
 176             curBeforeActions = new ArrayList<>(beforeActions);
 177             curAfterActions = new ArrayList<>(afterActions);
 178 
 179             selectFrameMethods(testMethod.getDeclaringClass(), BeforeEach.class).map(
 180                     this::wrap).forEachOrdered(curBeforeActions::add);
 181             selectFrameMethods(testMethod.getDeclaringClass(), AfterEach.class).map(
 182                     this::wrap).forEachOrdered(curAfterActions::add);
 183         }
 184 
 185         TestInstance test = new TestInstance(testBody, curBeforeActions,
 186                 curAfterActions, dryRun);
 187         if (includedTests == null) {
 188             trace(String.format("Create: %s", test.fullName()));
 189         }
 190         testConsumer.accept(test);
 191     }
 192 
 193     private void clear() {
 194         beforeActions = new ArrayList<>();
 195         afterActions = new ArrayList<>();
 196         excludedTests = null;
 197         includedTests = null;
 198         spaceSubstitute = null;
 199         testGroup = null;
 200     }
 201 
 202     private static Class probeClass(String name) {
 203         try {
 204             return Class.forName(name);
 205         } catch (ClassNotFoundException ex) {
 206             return null;
 207         }
 208     }
 209 
 210     private static Stream<Method> selectFrameMethods(Class type, Class annotationType) {
 211         return Stream.of(type.getMethods())
 212                 .filter(m -> m.getParameterCount() == 0)
 213                 .filter(m -> !m.isAnnotationPresent(Test.class))
 214                 .filter(m -> m.isAnnotationPresent(annotationType))
 215                 .sorted((a, b) -> a.getName().compareTo(b.getName()));
 216     }
 217 
 218     private static Stream<String> cmdLineArgValueToMethodNames(String v) {
 219         List<String> result = new ArrayList<>();
 220         String defaultClassName = null;
 221         for (String token : v.split(",")) {
 222             Class testSet = probeClass(token);
 223             if (testSet != null) {
 224                 // Test set class specified. Pull in all public methods
 225                 // from the class with @Test annotation removing name duplicates.
 226                 // Overloads will be handled at the next phase of processing.
 227                 defaultClassName = token;
 228                 Stream.of(testSet.getMethods()).filter(
 229                         m -> m.isAnnotationPresent(Test.class)).map(
 230                                 Method::getName).distinct().forEach(
 231                                 name -> result.add(String.join(".", token, name)));
 232 
 233                 continue;
 234             }
 235 
 236             final String qualifiedMethodName;
 237             final int lastDotIdx = token.lastIndexOf('.');
 238             if (lastDotIdx != -1) {
 239                 qualifiedMethodName = token;
 240                 defaultClassName = token.substring(0, lastDotIdx);
 241             } else if (defaultClassName == null) {
 242                 throw new ParseException("Default class name not found in");
 243             } else {
 244                 qualifiedMethodName = String.join(".", defaultClassName, token);
 245             }
 246             result.add(qualifiedMethodName);
 247         }
 248         return result.stream();
 249     }
 250 
 251     private static boolean filterMethod(String expectedMethodName, Method method) {
 252         if (!method.getName().equals(expectedMethodName)) {
 253             return false;
 254         }
 255         switch (method.getParameterCount()) {
 256             case 0:
 257                 return !isParametrized(method);
 258             case 1:
 259                 return isParametrized(method);
 260         }
 261         return false;
 262     }
 263 
 264     private static boolean isParametrized(Method method) {
 265         return method.isAnnotationPresent(ParameterGroup.class) || method.isAnnotationPresent(
 266                 Parameter.class);
 267     }
 268 
 269     private static List<Method> getJavaMethodFromString(
 270             String qualifiedMethodName) {
 271         int lastDotIdx = qualifiedMethodName.lastIndexOf('.');
 272         if (lastDotIdx == -1) {
 273             throw new ParseException("Class name not found in");
 274         }
 275         String className = qualifiedMethodName.substring(0, lastDotIdx);
 276         String methodName = qualifiedMethodName.substring(lastDotIdx + 1);
 277         Class methodClass;
 278         try {
 279             methodClass = Class.forName(className);
 280         } catch (ClassNotFoundException ex) {
 281             throw new ParseException(String.format("Class [%s] not found;",
 282                     className));
 283         }
 284         // Get the list of all public methods as need to deal with overloads.
 285         List<Method> methods = Stream.of(methodClass.getMethods()).filter(
 286                 (m) -> filterMethod(methodName, m)).collect(Collectors.toList());
 287         if (methods.isEmpty()) {
 288             throw new ParseException(String.format(
 289                     "Method [%s] not found in [%s] class;",
 290                     methodName, className));
 291         }
 292 
 293         trace(String.format("%s -> %s", qualifiedMethodName, methods));
 294         return methods;
 295     }
 296 
 297     private static Stream<Method> getJavaMethodsFromArg(String argValue) {
 298         return cmdLineArgValueToMethodNames(argValue).map(
 299                 ThrowingFunction.toFunction(
 300                         TestBuilder::getJavaMethodFromString)).flatMap(
 301                         List::stream).sequential();
 302     }
 303 
 304     private static Parameter[] getMethodParameters(Method method) {
 305         if (method.isAnnotationPresent(ParameterGroup.class)) {
 306             return ((ParameterGroup) method.getAnnotation(ParameterGroup.class)).value();
 307         }
 308 
 309         if (method.isAnnotationPresent(Parameter.class)) {
 310             return new Parameter[]{(Parameter) method.getAnnotation(
 311                 Parameter.class)};
 312         }
 313 
 314         // Unexpected
 315         return null;
 316     }
 317 
 318     private static Stream<Object[]> toCtorArgs(Method method) throws
 319             IllegalAccessException, InvocationTargetException {
 320         Class type = method.getDeclaringClass();
 321         List<Method> paremetersProviders = Stream.of(type.getMethods())
 322                 .filter(m -> m.getParameterCount() == 0)
 323                 .filter(m -> (m.getModifiers() & Modifier.STATIC) != 0)
 324                 .filter(m -> m.isAnnotationPresent(Parameters.class))
 325                 .sorted()
 326                 .collect(Collectors.toList());
 327         if (paremetersProviders.isEmpty()) {
 328             // Single instance using the default constructor.
 329             return Stream.ofNullable(MethodCall.DEFAULT_CTOR_ARGS);
 330         }
 331 
 332         // Pick the first method from the list.
 333         Method paremetersProvider = paremetersProviders.iterator().next();
 334         if (paremetersProviders.size() > 1) {
 335             trace(String.format(
 336                     "Found %d public static methods without arguments with %s annotation. Will use %s",
 337                     paremetersProviders.size(), Parameters.class,
 338                     paremetersProvider));
 339             paremetersProviders.stream().map(Method::toString).forEach(
 340                     TestBuilder::trace);
 341         }
 342 
 343         // Construct collection of arguments for test class instances.
 344         return ((Collection) paremetersProvider.invoke(null)).stream();
 345     }
 346 
 347     private static Stream<MethodCall> toMethodCalls(Method method) throws
 348             IllegalAccessException, InvocationTargetException {
 349         return toCtorArgs(method).map(v -> toMethodCalls(v, method)).flatMap(
 350                 s -> s).peek(methodCall -> {
 351                     // Make sure required constructor is accessible if the one is needed.
 352                     // Need to probe all methods as some of them might be static
 353                     // and some class members.
 354                     // Only class members require ctors.
 355                     try {
 356                         methodCall.checkRequiredConstructor();
 357                     } catch (NoSuchMethodException ex) {
 358                         throw new ParseException(ex.getMessage() + ".");
 359                     }
 360                 });
 361     }
 362 
 363     private static Stream<MethodCall> toMethodCalls(Object[] ctorArgs, Method method) {
 364         if (!isParametrized(method)) {
 365             return Stream.of(new MethodCall(ctorArgs, method));
 366         }
 367         Parameter[] annotations = getMethodParameters(method);
 368         if (annotations.length == 0) {
 369             return Stream.of(new MethodCall(ctorArgs, method));
 370         }
 371         return Stream.of(annotations).map((a) -> {
 372             Class paramType = method.getParameterTypes()[0];
 373             final Object annotationValue;
 374             if (!paramType.isArray()) {
 375                 annotationValue = fromString(a.value()[0], paramType);
 376             } else {
 377                 Class paramComponentType = paramType.getComponentType();
 378                 annotationValue = Array.newInstance(paramComponentType, a.value().length);
 379                 var idx = new AtomicInteger(-1);
 380                 Stream.of(a.value()).map(v -> fromString(v, paramComponentType)).sequential().forEach(
 381                         v -> Array.set(annotationValue, idx.incrementAndGet(), v));
 382             }
 383             return new MethodCall(ctorArgs, method, annotationValue);
 384         });
 385     }
 386 
 387     private static Object fromString(String value, Class toType) {
 388         Function<String, Object> converter = conv.get(toType);
 389         if (converter == null) {
 390             throw new RuntimeException(String.format(
 391                     "Failed to find a conversion of [%s] string to %s type",
 392                     value, toType));
 393         }
 394         return converter.apply(value);
 395     }
 396 
 397     // Wraps Method.invike() into ThrowingRunnable.run()
 398     private ThrowingConsumer wrap(Method method) {
 399         return (test) -> {
 400             Class methodClass = method.getDeclaringClass();
 401             String methodName = String.join(".", methodClass.getName(),
 402                     method.getName());
 403             TKit.log(String.format("[ CALL     ] %s()", methodName));
 404             if (!dryRun) {
 405                 if (methodClass.isInstance(test)) {
 406                     method.invoke(test);
 407                 } else {
 408                     method.invoke(null);
 409                 }
 410             }
 411         };
 412     }
 413 
 414     private static class ParseException extends IllegalArgumentException {
 415 
 416         ParseException(String msg) {
 417             super(msg);
 418         }
 419 
 420         void setContext(String badCmdLineArg) {
 421             this.badCmdLineArg = badCmdLineArg;
 422         }
 423 
 424         @Override
 425         public String getMessage() {
 426             String msg = super.getMessage();
 427             if (badCmdLineArg != null) {
 428                 msg = String.format("%s parameter=[%s]", msg, badCmdLineArg);
 429             }
 430             return msg;
 431         }
 432         private String badCmdLineArg;
 433     }
 434 
 435     static void trace(String msg) {
 436         if (TKit.VERBOSE_TEST_SETUP) {
 437             TKit.log(msg);
 438         }
 439     }
 440 
 441     private final Map<String, ThrowingConsumer<String>> argProcessors;
 442     private Consumer<TestInstance> testConsumer;
 443     private List<MethodCall> testGroup;
 444     private List<ThrowingConsumer> beforeActions;
 445     private List<ThrowingConsumer> afterActions;
 446     private Set<String> excludedTests;
 447     private Set<String> includedTests;
 448     private String spaceSubstitute;
 449     private boolean dryRun;
 450 
 451     private final static Map<Class, Function<String, Object>> conv = Map.of(
 452             boolean.class, Boolean::valueOf,
 453             Boolean.class, Boolean::valueOf,
 454             int.class, Integer::valueOf,
 455             Integer.class, Integer::valueOf,
 456             long.class, Long::valueOf,
 457             Long.class, Long::valueOf,
 458             String.class, String::valueOf);
 459 
 460     final static String CMDLINE_ARG_PREFIX = "--jpt-";
 461 }