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).orElse(new HashSet<String>())).add(arg),
  72 
  73                 CMDLINE_ARG_PREFIX + "include",
  74                 arg -> (includedTests = Optional.ofNullable(
  75                         includedTests).orElse(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(
 131                     v -> testDescription.contains(v));
 132             if (match) {
 133                 trace(String.format(logMsg + ": %s", testDescription));
 134             }
 135             return pred.apply(match);
 136         }).collect(Collectors.toList()).stream();
 137     }
 138 
 139     private Stream<MethodCall> filterTestGroup() {
 140         Objects.requireNonNull(testGroup);
 141 
 142         UnaryOperator<Set<String>> restoreSpaces = filters -> {
 143             if (spaceSubstitute == null || filters == null) {
 144                 return filters;
 145             }
 146             return filters.stream().map(
 147                     filter -> filter.replace(spaceSubstitute, " ")).collect(
 148                             Collectors.toSet());
 149         };
 150 
 151         if (includedTests != null) {
 152             return filterTests(testGroup.stream(), restoreSpaces.apply(
 153                     includedTests), x -> x, "Include");
 154         }
 155 
 156         return filterTests(testGroup.stream(),
 157                 restoreSpaces.apply(excludedTests), x -> !x, "Exclude");
 158     }
 159 
 160     private void flushTestGroup() {
 161         if (testGroup != null) {
 162             filterTestGroup().forEach(testBody -> createTestInstance(testBody));
 163             clear();
 164         }
 165     }
 166 
 167     private void createTestInstance(MethodCall testBody) {
 168         final List<ThrowingConsumer> curBeforeActions;
 169         final List<ThrowingConsumer> curAfterActions;
 170 
 171         Method testMethod = testBody.getMethod();
 172         if (Stream.of(BeforeEach.class, AfterEach.class).anyMatch(
 173                 type -> testMethod.isAnnotationPresent(type))) {
 174             curBeforeActions = beforeActions;
 175             curAfterActions = afterActions;
 176         } else {
 177             curBeforeActions = new ArrayList<>(beforeActions);
 178             curAfterActions = new ArrayList<>(afterActions);
 179 
 180             selectFrameMethods(testMethod.getDeclaringClass(), BeforeEach.class).map(
 181                     this::wrap).forEachOrdered(curBeforeActions::add);
 182             selectFrameMethods(testMethod.getDeclaringClass(), AfterEach.class).map(
 183                     this::wrap).forEachOrdered(curAfterActions::add);
 184         }
 185 
 186         TestInstance test = new TestInstance(testBody, curBeforeActions,
 187                 curAfterActions, dryRun);
 188         if (includedTests == null) {
 189             trace(String.format("Create: %s", test.fullName()));
 190         }
 191         testConsumer.accept(test);
 192     }
 193 
 194     private void clear() {
 195         beforeActions = new ArrayList<>();
 196         afterActions = new ArrayList<>();
 197         excludedTests = null;
 198         includedTests = null;
 199         spaceSubstitute = null;
 200         testGroup = null;
 201     }
 202 
 203     private static Class probeClass(String name) {
 204         try {
 205             return Class.forName(name);
 206         } catch (ClassNotFoundException ex) {
 207             return null;
 208         }
 209     }
 210 
 211     private static Stream<Method> selectFrameMethods(Class type, Class annotationType) {
 212         return Stream.of(type.getMethods())
 213                 .filter(m -> m.getParameterCount() == 0)
 214                 .filter(m -> !m.isAnnotationPresent(Test.class))
 215                 .filter(m -> m.isAnnotationPresent(annotationType))
 216                 .sorted((a, b) -> a.getName().compareTo(b.getName()));
 217     }
 218 
 219     private static Stream<String> cmdLineArgValueToMethodNames(String v) {
 220         List<String> result = new ArrayList<>();
 221         String defaultClassName = null;
 222         for (String token : v.split(",")) {
 223             Class testSet = probeClass(token);
 224             if (testSet != null) {
 225                 // Test set class specified. Pull in all public methods
 226                 // from the class with @Test annotation removing name duplicates.
 227                 // Overloads will be handled at the next phase of processing.
 228                 defaultClassName = token;
 229                 Stream.of(testSet.getMethods()).filter(
 230                         m -> m.isAnnotationPresent(Test.class)).map(
 231                                 Method::getName).distinct().forEach(
 232                                 name -> result.add(String.join(".", token, name)));
 233 
 234                 continue;
 235             }
 236 
 237             final String qualifiedMethodName;
 238             final int lastDotIdx = token.lastIndexOf('.');
 239             if (lastDotIdx != -1) {
 240                 qualifiedMethodName = token;
 241                 defaultClassName = token.substring(0, lastDotIdx);
 242             } else if (defaultClassName == null) {
 243                 throw new ParseException("Default class name not found in");
 244             } else {
 245                 qualifiedMethodName = String.join(".", defaultClassName, token);
 246             }
 247             result.add(qualifiedMethodName);
 248         }
 249         return result.stream();
 250     }
 251 
 252     private static boolean filterMethod(String expectedMethodName, Method method) {
 253         if (!method.getName().equals(expectedMethodName)) {
 254             return false;
 255         }
 256         switch (method.getParameterCount()) {
 257             case 0:
 258                 return !isParametrized(method);
 259             case 1:
 260                 return isParametrized(method);
 261         }
 262         return false;
 263     }
 264 
 265     private static boolean isParametrized(Method method) {
 266         return method.isAnnotationPresent(ParameterGroup.class) || method.isAnnotationPresent(
 267                 Parameter.class);
 268     }
 269 
 270     private static List<Method> getJavaMethodFromString(
 271             String qualifiedMethodName) {
 272         int lastDotIdx = qualifiedMethodName.lastIndexOf('.');
 273         if (lastDotIdx == -1) {
 274             throw new ParseException("Class name not found in");
 275         }
 276         String className = qualifiedMethodName.substring(0, lastDotIdx);
 277         String methodName = qualifiedMethodName.substring(lastDotIdx + 1);
 278         Class methodClass;
 279         try {
 280             methodClass = Class.forName(className);
 281         } catch (ClassNotFoundException ex) {
 282             throw new ParseException(String.format("Class [%s] not found;",
 283                     className));
 284         }
 285         // Get the list of all public methods as need to deal with overloads.
 286         List<Method> methods = Stream.of(methodClass.getMethods()).filter(
 287                 (m) -> filterMethod(methodName, m)).collect(Collectors.toList());
 288         if (methods.isEmpty()) {
 289             new ParseException(String.format(
 290                     "Method [%s] not found in [%s] class;",
 291                     methodName, className));
 292         }
 293 
 294         trace(String.format("%s -> %s", qualifiedMethodName, methods));
 295         return methods;
 296     }
 297 
 298     private static Stream<Method> getJavaMethodsFromArg(String argValue) {
 299         return cmdLineArgValueToMethodNames(argValue).map(
 300                 ThrowingFunction.toFunction(
 301                         TestBuilder::getJavaMethodFromString)).flatMap(
 302                         List::stream).sequential();
 303     }
 304 
 305     private static Parameter[] getMethodParameters(Method method) {
 306         if (method.isAnnotationPresent(ParameterGroup.class)) {
 307             return ((ParameterGroup) method.getAnnotation(ParameterGroup.class)).value();
 308         }
 309 
 310         if (method.isAnnotationPresent(Parameter.class)) {
 311             return new Parameter[]{(Parameter) method.getAnnotation(
 312                 Parameter.class)};
 313         }
 314 
 315         // Unexpected
 316         return null;
 317     }
 318 
 319     private static Stream<Object[]> toCtorArgs(Method method) throws
 320             IllegalAccessException, InvocationTargetException {
 321         Class type = method.getDeclaringClass();
 322         List<Method> paremetersProviders = Stream.of(type.getMethods())
 323                 .filter(m -> m.getParameterCount() == 0)
 324                 .filter(m -> (m.getModifiers() & Modifier.STATIC) != 0)
 325                 .filter(m -> m.isAnnotationPresent(Parameters.class))
 326                 .sorted()
 327                 .collect(Collectors.toList());
 328         if (paremetersProviders.isEmpty()) {
 329             // Single instance using the default constructor.
 330             return Stream.ofNullable(MethodCall.DEFAULT_CTOR_ARGS);
 331         }
 332 
 333         // Pick the first method from the list.
 334         Method paremetersProvider = paremetersProviders.iterator().next();
 335         if (paremetersProviders.size() > 1) {
 336             trace(String.format(
 337                     "Found %d public static methods without arguments with %s annotation. Will use %s",
 338                     paremetersProviders.size(), Parameters.class,
 339                     paremetersProvider));
 340             paremetersProviders.stream().map(Method::toString).forEach(
 341                     TestBuilder::trace);
 342         }
 343 
 344         // Construct collection of arguments for test class instances.
 345         return ((Collection) paremetersProvider.invoke(null)).stream();
 346     }
 347 
 348     private static Stream<MethodCall> toMethodCalls(Method method) throws
 349             IllegalAccessException, InvocationTargetException {
 350         return toCtorArgs(method).map(v -> toMethodCalls(v, method)).flatMap(
 351                 s -> s).peek(methodCall -> {
 352                     // Make sure required constructor is accessible if the one is needed.
 353                     // Need to probe all methods as some of them might be static
 354                     // and some class members.
 355                     // Only class members require ctors.
 356                     try {
 357                         methodCall.checkRequiredConstructor();
 358                     } catch (NoSuchMethodException ex) {
 359                         throw new ParseException(ex.getMessage() + ".");
 360                     }
 361                 });
 362     }
 363 
 364     private static Stream<MethodCall> toMethodCalls(Object[] ctorArgs, Method method) {
 365         if (!isParametrized(method)) {
 366             return Stream.of(new MethodCall(ctorArgs, method));
 367         }
 368         Parameter[] annotations = getMethodParameters(method);
 369         if (annotations.length == 0) {
 370             return Stream.of(new MethodCall(ctorArgs, method));
 371         }
 372         return Stream.of(annotations).map((a) -> {
 373             Class paramType = method.getParameterTypes()[0];
 374             final Object annotationValue;
 375             if (!paramType.isArray()) {
 376                 annotationValue = fromString(a.value()[0], paramType);
 377             } else {
 378                 Class paramComponentType = paramType.getComponentType();
 379                 annotationValue = Array.newInstance(paramComponentType, a.value().length);
 380                 var idx = new AtomicInteger(-1);
 381                 Stream.of(a.value()).map(v -> fromString(v, paramComponentType)).sequential().forEach(
 382                         v -> Array.set(annotationValue, idx.incrementAndGet(), v));
 383             }
 384             return new MethodCall(ctorArgs, method, annotationValue);
 385         });
 386     }
 387 
 388     private static Object fromString(String value, Class toType) {
 389         Function<String, Object> converter = conv.get(toType);
 390         if (converter == null) {
 391             throw new RuntimeException(String.format(
 392                     "Failed to find a conversion of [%s] string to %s type",
 393                     value, toType));
 394         }
 395         return converter.apply(value);
 396     }
 397 
 398     // Wraps Method.invike() into ThrowingRunnable.run()
 399     private ThrowingConsumer wrap(Method method) {
 400         return (test) -> {
 401             Class methodClass = method.getDeclaringClass();
 402             String methodName = String.join(".", methodClass.getName(),
 403                     method.getName());
 404             TKit.log(String.format("[ CALL     ] %s()", methodName));
 405             if (!dryRun) {
 406                 if (methodClass.isInstance(test)) {
 407                     method.invoke(test);
 408                 } else {
 409                     method.invoke(null);
 410                 }
 411             }
 412         };
 413     }
 414 
 415     private static class ParseException extends IllegalArgumentException {
 416 
 417         ParseException(String msg) {
 418             super(msg);
 419         }
 420 
 421         void setContext(String badCmdLineArg) {
 422             this.badCmdLineArg = badCmdLineArg;
 423         }
 424 
 425         @Override
 426         public String getMessage() {
 427             String msg = super.getMessage();
 428             if (badCmdLineArg != null) {
 429                 msg = String.format("%s parameter=[%s]", msg, badCmdLineArg);
 430             }
 431             return msg;
 432         }
 433         private String badCmdLineArg;
 434     }
 435 
 436     static void trace(String msg) {
 437         if (TKit.VERBOSE_TEST_SETUP) {
 438             TKit.log(msg);
 439         }
 440     }
 441 
 442     private final Map<String, ThrowingConsumer<String>> argProcessors;
 443     private Consumer<TestInstance> testConsumer;
 444     private List<MethodCall> testGroup;
 445     private List<ThrowingConsumer> beforeActions;
 446     private List<ThrowingConsumer> afterActions;
 447     private Set<String> excludedTests;
 448     private Set<String> includedTests;
 449     private String spaceSubstitute;
 450     private boolean dryRun;
 451 
 452     private final static Map<Class, Function<String, Object>> conv = Map.of(
 453             boolean.class, Boolean::valueOf,
 454             Boolean.class, Boolean::valueOf,
 455             int.class, Integer::valueOf,
 456             Integer.class, Integer::valueOf,
 457             long.class, Long::valueOf,
 458             Long.class, Long::valueOf,
 459             String.class, String::valueOf);
 460 
 461     final static String CMDLINE_ARG_PREFIX = "--jpt-";
 462 }