8232279 : Improve test helpers #2
Submitted-by: asemenyuk
Reviewed-by: aherrick, almatvee
/*
* Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved.
* DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
*
* This code is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 2 only, as
* published by the Free Software Foundation.
*
* This code is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
* version 2 for more details (a copy is included in the LICENSE file that
* accompanied this code).
*
* You should have received a copy of the GNU General Public License version
* 2 along with this work; if not, write to the Free Software Foundation,
* Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
*
* Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
* or visit www.oracle.com if you need additional information or have any
* questions.
*/
package jdk.jpackage.test;
import java.lang.reflect.Array;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import jdk.jpackage.test.Annotations.AfterEach;
import jdk.jpackage.test.Annotations.BeforeEach;
import jdk.jpackage.test.Annotations.Parameter;
import jdk.jpackage.test.Annotations.ParameterGroup;
import jdk.jpackage.test.Annotations.Parameters;
import jdk.jpackage.test.Annotations.Test;
import jdk.jpackage.test.Functional.ThrowingConsumer;
import jdk.jpackage.test.Functional.ThrowingFunction;
final class TestBuilder implements AutoCloseable {
@Override
public void close() throws Exception {
flushTestGroup();
}
TestBuilder(Consumer<TestInstance> testConsumer) {
argProcessors = Map.of(
CMDLINE_ARG_PREFIX + "after-run",
arg -> getJavaMethodsFromArg(arg).map(
this::wrap).forEachOrdered(afterActions::add),
CMDLINE_ARG_PREFIX + "before-run",
arg -> getJavaMethodsFromArg(arg).map(
this::wrap).forEachOrdered(beforeActions::add),
CMDLINE_ARG_PREFIX + "run",
arg -> addTestGroup(getJavaMethodsFromArg(arg).map(
ThrowingFunction.toFunction(
TestBuilder::toMethodCalls)).flatMap(s -> s).collect(
Collectors.toList())),
CMDLINE_ARG_PREFIX + "exclude",
arg -> (excludedTests = Optional.ofNullable(
excludedTests).orElse(new HashSet<String>())).add(arg),
CMDLINE_ARG_PREFIX + "include",
arg -> (includedTests = Optional.ofNullable(
includedTests).orElse(new HashSet<String>())).add(arg),
CMDLINE_ARG_PREFIX + "space-subst",
arg -> spaceSubstitute = arg,
CMDLINE_ARG_PREFIX + "group",
arg -> flushTestGroup(),
CMDLINE_ARG_PREFIX + "dry-run",
arg -> dryRun = true
);
this.testConsumer = testConsumer;
clear();
}
void processCmdLineArg(String arg) throws Throwable {
int separatorIdx = arg.indexOf('=');
final String argName;
final String argValue;
if (separatorIdx != -1) {
argName = arg.substring(0, separatorIdx);
argValue = arg.substring(separatorIdx + 1);
} else {
argName = arg;
argValue = null;
}
try {
ThrowingConsumer<String> argProcessor = argProcessors.get(argName);
if (argProcessor == null) {
throw new ParseException("Unrecognized");
}
argProcessor.accept(argValue);
} catch (ParseException ex) {
ex.setContext(arg);
throw ex;
}
}
private void addTestGroup(List<MethodCall> newTestGroup) {
if (testGroup != null) {
testGroup.addAll(newTestGroup);
} else {
testGroup = newTestGroup;
}
}
private static Stream<MethodCall> filterTests(Stream<MethodCall> tests,
Set<String> filters, UnaryOperator<Boolean> pred, String logMsg) {
if (filters == null) {
return tests;
}
// Log all matches before returning from the function
return tests.filter(test -> {
String testDescription = test.createDescription().testFullName();
boolean match = filters.stream().anyMatch(
v -> testDescription.contains(v));
if (match) {
trace(String.format(logMsg + ": %s", testDescription));
}
return pred.apply(match);
}).collect(Collectors.toList()).stream();
}
private Stream<MethodCall> filterTestGroup() {
Objects.requireNonNull(testGroup);
UnaryOperator<Set<String>> restoreSpaces = filters -> {
if (spaceSubstitute == null || filters == null) {
return filters;
}
return filters.stream().map(
filter -> filter.replace(spaceSubstitute, " ")).collect(
Collectors.toSet());
};
if (includedTests != null) {
return filterTests(testGroup.stream(), restoreSpaces.apply(
includedTests), x -> x, "Include");
}
return filterTests(testGroup.stream(),
restoreSpaces.apply(excludedTests), x -> !x, "Exclude");
}
private void flushTestGroup() {
if (testGroup != null) {
filterTestGroup().forEach(testBody -> createTestInstance(testBody));
clear();
}
}
private void createTestInstance(MethodCall testBody) {
final List<ThrowingConsumer> curBeforeActions;
final List<ThrowingConsumer> curAfterActions;
Method testMethod = testBody.getMethod();
if (Stream.of(BeforeEach.class, AfterEach.class).anyMatch(
type -> testMethod.isAnnotationPresent(type))) {
curBeforeActions = beforeActions;
curAfterActions = afterActions;
} else {
curBeforeActions = new ArrayList<>(beforeActions);
curAfterActions = new ArrayList<>(afterActions);
selectFrameMethods(testMethod.getDeclaringClass(), BeforeEach.class).map(
this::wrap).forEachOrdered(curBeforeActions::add);
selectFrameMethods(testMethod.getDeclaringClass(), AfterEach.class).map(
this::wrap).forEachOrdered(curAfterActions::add);
}
TestInstance test = new TestInstance(testBody, curBeforeActions,
curAfterActions, dryRun);
if (includedTests == null) {
trace(String.format("Create: %s", test.fullName()));
}
testConsumer.accept(test);
}
private void clear() {
beforeActions = new ArrayList<>();
afterActions = new ArrayList<>();
excludedTests = null;
includedTests = null;
spaceSubstitute = null;
testGroup = null;
}
private static Class probeClass(String name) {
try {
return Class.forName(name);
} catch (ClassNotFoundException ex) {
return null;
}
}
private static Stream<Method> selectFrameMethods(Class type, Class annotationType) {
return Stream.of(type.getMethods())
.filter(m -> m.getParameterCount() == 0)
.filter(m -> !m.isAnnotationPresent(Test.class))
.filter(m -> m.isAnnotationPresent(annotationType))
.sorted((a, b) -> a.getName().compareTo(b.getName()));
}
private static Stream<String> cmdLineArgValueToMethodNames(String v) {
List<String> result = new ArrayList<>();
String defaultClassName = null;
for (String token : v.split(",")) {
Class testSet = probeClass(token);
if (testSet != null) {
// Test set class specified. Pull in all public methods
// from the class with @Test annotation removing name duplicates.
// Overloads will be handled at the next phase of processing.
defaultClassName = token;
Stream.of(testSet.getMethods()).filter(
m -> m.isAnnotationPresent(Test.class)).map(
Method::getName).distinct().forEach(
name -> result.add(String.join(".", token, name)));
continue;
}
final String qualifiedMethodName;
final int lastDotIdx = token.lastIndexOf('.');
if (lastDotIdx != -1) {
qualifiedMethodName = token;
defaultClassName = token.substring(0, lastDotIdx);
} else if (defaultClassName == null) {
throw new ParseException("Default class name not found in");
} else {
qualifiedMethodName = String.join(".", defaultClassName, token);
}
result.add(qualifiedMethodName);
}
return result.stream();
}
private static boolean filterMethod(String expectedMethodName, Method method) {
if (!method.getName().equals(expectedMethodName)) {
return false;
}
switch (method.getParameterCount()) {
case 0:
return !isParametrized(method);
case 1:
return isParametrized(method);
}
return false;
}
private static boolean isParametrized(Method method) {
return method.isAnnotationPresent(ParameterGroup.class) || method.isAnnotationPresent(
Parameter.class);
}
private static List<Method> getJavaMethodFromString(
String qualifiedMethodName) {
int lastDotIdx = qualifiedMethodName.lastIndexOf('.');
if (lastDotIdx == -1) {
throw new ParseException("Class name not found in");
}
String className = qualifiedMethodName.substring(0, lastDotIdx);
String methodName = qualifiedMethodName.substring(lastDotIdx + 1);
Class methodClass;
try {
methodClass = Class.forName(className);
} catch (ClassNotFoundException ex) {
throw new ParseException(String.format("Class [%s] not found;",
className));
}
// Get the list of all public methods as need to deal with overloads.
List<Method> methods = Stream.of(methodClass.getMethods()).filter(
(m) -> filterMethod(methodName, m)).collect(Collectors.toList());
if (methods.isEmpty()) {
new ParseException(String.format(
"Method [%s] not found in [%s] class;",
methodName, className));
}
trace(String.format("%s -> %s", qualifiedMethodName, methods));
return methods;
}
private static Stream<Method> getJavaMethodsFromArg(String argValue) {
return cmdLineArgValueToMethodNames(argValue).map(
ThrowingFunction.toFunction(
TestBuilder::getJavaMethodFromString)).flatMap(
List::stream).sequential();
}
private static Parameter[] getMethodParameters(Method method) {
if (method.isAnnotationPresent(ParameterGroup.class)) {
return ((ParameterGroup) method.getAnnotation(ParameterGroup.class)).value();
}
if (method.isAnnotationPresent(Parameter.class)) {
return new Parameter[]{(Parameter) method.getAnnotation(
Parameter.class)};
}
// Unexpected
return null;
}
private static Stream<Object[]> toCtorArgs(Method method) throws
IllegalAccessException, InvocationTargetException {
Class type = method.getDeclaringClass();
List<Method> paremetersProviders = Stream.of(type.getMethods())
.filter(m -> m.getParameterCount() == 0)
.filter(m -> (m.getModifiers() & Modifier.STATIC) != 0)
.filter(m -> m.isAnnotationPresent(Parameters.class))
.sorted()
.collect(Collectors.toList());
if (paremetersProviders.isEmpty()) {
// Single instance using the default constructor.
return Stream.ofNullable(MethodCall.DEFAULT_CTOR_ARGS);
}
// Pick the first method from the list.
Method paremetersProvider = paremetersProviders.iterator().next();
if (paremetersProviders.size() > 1) {
trace(String.format(
"Found %d public static methods without arguments with %s annotation. Will use %s",
paremetersProviders.size(), Parameters.class,
paremetersProvider));
paremetersProviders.stream().map(Method::toString).forEach(
TestBuilder::trace);
}
// Construct collection of arguments for test class instances.
return ((Collection) paremetersProvider.invoke(null)).stream();
}
private static Stream<MethodCall> toMethodCalls(Method method) throws
IllegalAccessException, InvocationTargetException {
return toCtorArgs(method).map(v -> toMethodCalls(v, method)).flatMap(
s -> s).peek(methodCall -> {
// Make sure required constructor is accessible if the one is needed.
// Need to probe all methods as some of them might be static
// and some class members.
// Only class members require ctors.
try {
methodCall.checkRequiredConstructor();
} catch (NoSuchMethodException ex) {
throw new ParseException(ex.getMessage() + ".");
}
});
}
private static Stream<MethodCall> toMethodCalls(Object[] ctorArgs, Method method) {
if (!isParametrized(method)) {
return Stream.of(new MethodCall(ctorArgs, method));
}
Parameter[] annotations = getMethodParameters(method);
if (annotations.length == 0) {
return Stream.of(new MethodCall(ctorArgs, method));
}
return Stream.of(annotations).map((a) -> {
Class paramType = method.getParameterTypes()[0];
final Object annotationValue;
if (!paramType.isArray()) {
annotationValue = fromString(a.value()[0], paramType);
} else {
Class paramComponentType = paramType.getComponentType();
annotationValue = Array.newInstance(paramComponentType, a.value().length);
var idx = new AtomicInteger(-1);
Stream.of(a.value()).map(v -> fromString(v, paramComponentType)).sequential().forEach(
v -> Array.set(annotationValue, idx.incrementAndGet(), v));
}
return new MethodCall(ctorArgs, method, annotationValue);
});
}
private static Object fromString(String value, Class toType) {
Function<String, Object> converter = conv.get(toType);
if (converter == null) {
throw new RuntimeException(String.format(
"Failed to find a conversion of [%s] string to %s type",
value, toType));
}
return converter.apply(value);
}
// Wraps Method.invike() into ThrowingRunnable.run()
private ThrowingConsumer wrap(Method method) {
return (test) -> {
Class methodClass = method.getDeclaringClass();
String methodName = String.join(".", methodClass.getName(),
method.getName());
TKit.log(String.format("[ CALL ] %s()", methodName));
if (!dryRun) {
if (methodClass.isInstance(test)) {
method.invoke(test);
} else {
method.invoke(null);
}
}
};
}
private static class ParseException extends IllegalArgumentException {
ParseException(String msg) {
super(msg);
}
void setContext(String badCmdLineArg) {
this.badCmdLineArg = badCmdLineArg;
}
@Override
public String getMessage() {
String msg = super.getMessage();
if (badCmdLineArg != null) {
msg = String.format("%s parameter=[%s]", msg, badCmdLineArg);
}
return msg;
}
private String badCmdLineArg;
}
static void trace(String msg) {
if (TKit.VERBOSE_TEST_SETUP) {
TKit.log(msg);
}
}
private final Map<String, ThrowingConsumer<String>> argProcessors;
private Consumer<TestInstance> testConsumer;
private List<MethodCall> testGroup;
private List<ThrowingConsumer> beforeActions;
private List<ThrowingConsumer> afterActions;
private Set<String> excludedTests;
private Set<String> includedTests;
private String spaceSubstitute;
private boolean dryRun;
private final static Map<Class, Function<String, Object>> conv = Map.of(
boolean.class, Boolean::valueOf,
Boolean.class, Boolean::valueOf,
int.class, Integer::valueOf,
Integer.class, Integer::valueOf,
long.class, Long::valueOf,
Long.class, Long::valueOf,
String.class, String::valueOf);
final static String CMDLINE_ARG_PREFIX = "--jpt-";
}