[Java 11 Desugar Support] Testing Infrastructures - 3/N
#java11 #desugar
- Add a DesugarRule for convenient testing and debugging.
PiperOrigin-RevId: 282996823
diff --git a/src/test/java/com/google/devtools/build/android/desugar/DesugarRule.java b/src/test/java/com/google/devtools/build/android/desugar/DesugarRule.java
new file mode 100644
index 0000000..9bf35a6
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/android/desugar/DesugarRule.java
@@ -0,0 +1,399 @@
+/*
+ * Copyright 2019 The Bazel Authors. All rights reserved.
+ *
+ * Licensed 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.
+ */
+
+package com.google.devtools.build.android.desugar;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.UsedReflectively;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.Multimap;
+import com.google.devtools.build.runtime.RunfilesPaths;
+import com.google.errorprone.annotations.FormatMethod;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodHandles.Lookup;
+import java.lang.management.ManagementFactory;
+import java.lang.management.RuntimeMXBean;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+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 org.junit.rules.TemporaryFolder;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.RunWith;
+import org.junit.runners.model.Statement;
+
+/** A JUnit4 Rule that desugars an input jar file and load the transformed jar to JVM. */
+public class DesugarRule implements TestRule {
+
+ /**
+ * Identifies injectable class-literal fields with the specified class to load at runtime and
+ * assign to the field. An injectable class-literal field may have any access modifier (private,
+ * package-private, protected, public). Sample usage:
+ *
+ * <pre><code>
+ * @RunWith(JUnit4.class)
+ * public class DesugarRuleTest {
+ *
+ * @Rule
+ * public final DesugarRule desugarRule =
+ * DesugarRule.builder(this, MethodHandles.lookup())
+ * .addRuntimeInputs("MyJar.jar")
+ * .build();
+ *
+ * @LoadClass("my.package.ClassToDesugar")
+ * private Class<?> classToDesugarClass;f
+ *
+ * // ... Test methods ...
+ * }
+ * </code></pre>
+ */
+ @UsedReflectively
+ @Documented
+ @Target(ElementType.FIELD)
+ @Retention(RetentionPolicy.RUNTIME)
+ public @interface LoadClass {
+
+ /**
+ * The fully-qualified class name of the class to load. The format agrees with {@link
+ * Class#getName}.
+ */
+ String value();
+ }
+
+ private static final Path ANDROID_RUNTIME_JAR_PATH =
+ RunfilesPaths.resolve(
+ "third_party/java/android/android_sdk_linux/platforms/experimental/android_blaze.jar");
+
+ private static final Path JACOCO_RUNTIME_PATH =
+ RunfilesPaths.resolve("third_party/java/jacoco/jacoco_agent.jar");
+
+ private static final String DEFAULT_OUTPUT_ROOT_PREFIX = "desugared_dump";
+
+ /** The transformation record that describes the desugaring of a jar. */
+ @AutoValue
+ abstract static class JarTransformationRecord {
+
+ private static final ClassLoader baseClassLoader =
+ ClassLoader.getSystemClassLoader().getParent();
+
+ /**
+ * The full runtime path of a pre-transformationRecord jar.
+ *
+ * @see Desugar.DesugarOptions#inputJars for details.
+ */
+ abstract ImmutableList<Path> inputJars();
+
+ /**
+ * The full runtime path of a post-transformationRecord jar (deguared jar).
+ *
+ * @see Desugar.DesugarOptions#inputJars for details.
+ */
+ abstract ImmutableList<Path> outputJars();
+
+ /** @see Desugar.DesugarOptions#classpath for details. */
+ abstract ImmutableList<Path> classPathEntries();
+
+ /** @see Desugar.DesugarOptions#bootclasspath for details. */
+ abstract ImmutableList<Path> bootClassPathEntries();
+
+ /** The remaining command options used for desugaring. */
+ abstract ImmutableListMultimap<String, String> extraCustomCommandOptions();
+
+ /** The factory method of this jar transformation record. */
+ static JarTransformationRecord create(
+ ImmutableList<Path> inputJars,
+ ImmutableList<Path> outputJars,
+ ImmutableList<Path> classPathEntries,
+ ImmutableList<Path> bootClassPathEntries,
+ ImmutableListMultimap<String, String> extraCustomCommandOptions) {
+ return new AutoValue_DesugarRule_JarTransformationRecord(
+ inputJars, outputJars, classPathEntries, bootClassPathEntries, extraCustomCommandOptions);
+ }
+
+ final ImmutableList<String> getDesugarFlags() {
+ ImmutableList.Builder<String> args = ImmutableList.builder();
+ inputJars().forEach(path -> args.add("--input=" + path));
+ outputJars().forEach(path -> args.add("--output=" + path));
+ classPathEntries().forEach(path -> args.add("--classpath_entry=" + path));
+ bootClassPathEntries().forEach(path -> args.add("--bootclasspath_entry=" + path));
+ extraCustomCommandOptions().forEach((k, v) -> args.add("--" + k + "=" + v));
+ return args.build();
+ }
+
+ final ClassLoader getOutputClassLoader() throws MalformedURLException {
+ List<URL> urls = new ArrayList<>();
+ for (Path path : Iterables.concat(outputJars(), classPathEntries(), bootClassPathEntries())) {
+ urls.add(path.toUri().toURL());
+ }
+ return URLClassLoader.newInstance(urls.toArray(new URL[0]), baseClassLoader);
+ }
+ }
+
+ /** For hosting desugared jar temporarily. */
+ private final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private final Object testInstance;
+ private final MethodHandles.Lookup testInstanceLookup;
+ private final ImmutableList<Path> inputs;
+ private final ImmutableList<Path> classPathEntries;
+ private final ImmutableList<Path> bootClassPathEntries;
+ private final ImmutableList<Field> fieldForDynamicClassLoading;
+ private final ImmutableListMultimap<String, String> extraCustomCommandOptions;
+
+ /** The state of the already-created directories to avoid directory re-creation. */
+ private final Map<String, Path> tempDirs = new HashMap<>();
+
+ /**
+ * The entry point to create a {@link DesugarRule}.
+ *
+ * @param testInstance The <code>this</code> reference of the JUnit test class.
+ * @param testInstanceLookup The lookup object from the test class, i.e.<code>
+ * MethodHandles.lookup()</code>
+ */
+ public static DesugarRuleBuilder builder(Object testInstance, Lookup testInstanceLookup) {
+ return new DesugarRuleBuilder(testInstance, testInstanceLookup);
+ }
+
+ private DesugarRule(
+ DesugarRuleBuilder desugarRuleBuilder,
+ MethodHandles.Lookup testInstanceLookup,
+ ImmutableList<Path> inputJars,
+ ImmutableList<Path> classPathEntries,
+ ImmutableList<Path> bootClassPathEntries) {
+ this.testInstance = desugarRuleBuilder.testInstance;
+ this.testInstanceLookup = testInstanceLookup;
+ this.inputs = inputJars;
+ this.classPathEntries = classPathEntries;
+ this.bootClassPathEntries = bootClassPathEntries;
+ this.extraCustomCommandOptions =
+ ImmutableListMultimap.copyOf(desugarRuleBuilder.customCommandOptions);
+ this.fieldForDynamicClassLoading =
+ findAllFieldsWithAnnotation(testInstance.getClass(), LoadClass.class);
+ }
+
+ @Override
+ public Statement apply(Statement base, Description description) {
+ return temporaryFolder.apply(
+ new Statement() {
+ @Override
+ public void evaluate() throws Throwable {
+ JarTransformationRecord transformationRecord =
+ JarTransformationRecord.create(
+ inputs,
+ getRuntimeOutputPaths(inputs, temporaryFolder, tempDirs),
+ classPathEntries,
+ bootClassPathEntries,
+ extraCustomCommandOptions);
+ Desugar.main(transformationRecord.getDesugarFlags().toArray(new String[0]));
+
+ ClassLoader outputJarClassLoader = transformationRecord.getOutputClassLoader();
+ for (Field field : fieldForDynamicClassLoading) {
+ String qualifiedClassName = field.getDeclaredAnnotation(LoadClass.class).value();
+ Class<?> classLiteral = outputJarClassLoader.loadClass(qualifiedClassName);
+ MethodHandle fieldSetter = testInstanceLookup.unreflectSetter(field);
+ fieldSetter.invoke(testInstance, classLiteral);
+ }
+ base.evaluate();
+ }
+ },
+ description);
+ }
+
+ private static ImmutableList<Path> getRuntimeOutputPaths(
+ ImmutableList<Path> inputs, TemporaryFolder temporaryFolder, Map<String, Path> tempDirs)
+ throws IOException {
+ ImmutableList.Builder<Path> outputRuntimePathsBuilder = ImmutableList.builder();
+ for (Path path : inputs) {
+ String targetDirKey = Paths.get(DEFAULT_OUTPUT_ROOT_PREFIX) + "/" + path.getParent();
+ final Path outputDirPath;
+ if (tempDirs.containsKey(targetDirKey)) {
+ outputDirPath = tempDirs.get(targetDirKey);
+ } else {
+ outputDirPath = Paths.get(temporaryFolder.newFolder(targetDirKey).getPath());
+ tempDirs.put(targetDirKey, outputDirPath);
+ }
+ outputRuntimePathsBuilder.add(outputDirPath.resolve(path.getFileName()));
+ }
+ return outputRuntimePathsBuilder.build();
+ }
+
+ private static ImmutableList<Field> findAllFieldsWithAnnotation(
+ Class<?> testClass, Class<? extends Annotation> annotationClass) {
+ ImmutableList.Builder<Field> fields = ImmutableList.builder();
+ for (Class<?> currentClass = testClass;
+ currentClass != null;
+ currentClass = currentClass.getSuperclass()) {
+ for (Field field : testClass.getDeclaredFields()) {
+ if (field.isAnnotationPresent(annotationClass)) {
+ fields.add(field);
+ }
+ }
+ }
+ return fields.build();
+ }
+
+ /** The builder class for {@link DesugarRule}. */
+ public static class DesugarRuleBuilder {
+
+ private final Object testInstance;
+ private final MethodHandles.Lookup testInstanceLookup;
+ private final List<Path> inputs = new ArrayList<>();
+ private final List<Path> classPathEntries = new ArrayList<>();
+ private final List<Path> bootClassPathEntries = new ArrayList<>();
+ private final Multimap<String, String> customCommandOptions = LinkedHashMultimap.create();
+ private final ErrorMessenger errorMessenger = new ErrorMessenger();
+
+ DesugarRuleBuilder(Object testInstance, MethodHandles.Lookup testInstanceLookup) {
+ this.testInstance = testInstance;
+ this.testInstanceLookup = testInstanceLookup;
+ Class<?> testClass = testInstance.getClass();
+ if (testClass != testInstanceLookup.lookupClass()) {
+ errorMessenger.addError(
+ "Expected testInstanceLookup has private access to (%s), but get (%s). Have you"
+ + " passed MethodHandles.lookup() to testInstanceLookup in test class?",
+ testClass, testInstanceLookup.lookupClass());
+ }
+ if (!testClass.isAnnotationPresent(RunWith.class)) {
+ errorMessenger.addError(
+ "Expected a test instance whose class is annotated with @RunWith. %s", testClass);
+ }
+
+ for (Field field : findAllFieldsWithAnnotation(testClass, LoadClass.class)) {
+ if (Modifier.isStatic(field.getModifiers())) {
+ errorMessenger.addError("Expected to be non-static for field (%s)", field);
+ }
+
+ if (field.getType() != Class.class) {
+ errorMessenger.addError("Expected a class literal type (Class<?>) for field (%s)", field);
+ }
+ }
+ }
+
+ public DesugarRuleBuilder addRuntimeInputs(String... inputJars) {
+ Arrays.stream(inputJars).map(RunfilesPaths::resolve).forEach(this::addInputs);
+ return this;
+ }
+
+ public DesugarRuleBuilder addInputs(Path... inputJars) {
+ for (Path path : inputJars) {
+ if (!path.toString().endsWith(".jar")) {
+ errorMessenger.addError("Expected a JAR file (*.jar): Actual (%s)", path);
+ }
+ if (!Files.exists(path)) {
+ errorMessenger.addError("File does not exist: %s", path);
+ }
+ inputs.add(path);
+ }
+ return this;
+ }
+
+ public DesugarRuleBuilder addClasspathEntries(Path... inputJars) {
+ Collections.addAll(classPathEntries, inputJars);
+ return this;
+ }
+
+ public DesugarRuleBuilder addBootClassPathEntries(Path... inputJars) {
+ Collections.addAll(bootClassPathEntries, inputJars);
+ return this;
+ }
+
+ /** Format: --<key>=<value> */
+ public DesugarRuleBuilder addCommandOptions(String key, String value) {
+ customCommandOptions.put(key, value);
+ return this;
+ }
+
+ private void checkJVMOptions() {
+ RuntimeMXBean runtimeMxBean = ManagementFactory.getRuntimeMXBean();
+ List<String> arguments = runtimeMxBean.getInputArguments();
+ if (arguments.stream()
+ .noneMatch(arg -> arg.startsWith("-Djdk.internal.lambda.dumpProxyClasses="))) {
+ errorMessenger.addError(
+ "Expected \"-Djdk.internal.lambda.dumpProxyClasses=$$(mktemp -d)\" in jvm_flags.\n");
+ }
+ }
+
+ public DesugarRule build() {
+ checkJVMOptions();
+ if (bootClassPathEntries.isEmpty()
+ && !customCommandOptions.containsKey("allow_empty_bootclasspath")) {
+ addCommandOptions("bootclasspath_entry", ANDROID_RUNTIME_JAR_PATH.toString());
+ }
+
+ if (errorMessenger.anyError()) {
+ throw new IllegalStateException(
+ String.format(
+ "Invalid Desugar configurations:\n%s\n", errorMessenger.getAllMessages()));
+ }
+
+ addClasspathEntries(JACOCO_RUNTIME_PATH);
+
+ return new DesugarRule(
+ this,
+ testInstanceLookup,
+ ImmutableList.copyOf(inputs),
+ ImmutableList.copyOf(classPathEntries),
+ ImmutableList.copyOf(bootClassPathEntries));
+ }
+ }
+
+ /** A messenger that manages desugar configuration errors. */
+ private static class ErrorMessenger {
+
+ private final List<String> errorMessages = new ArrayList<>();
+
+ @FormatMethod
+ ErrorMessenger addError(String recipe, Object... args) {
+ errorMessages.add(String.format(recipe, args));
+ return this;
+ }
+
+ boolean anyError() {
+ return !errorMessages.isEmpty();
+ }
+
+ List<String> getAllMessages() {
+ return errorMessages;
+ }
+
+ @Override
+ public String toString() {
+ return getAllMessages().toString();
+ }
+ }
+}
diff --git a/src/test/java/com/google/devtools/build/android/desugar/DesugarRuleTest.java b/src/test/java/com/google/devtools/build/android/desugar/DesugarRuleTest.java
new file mode 100644
index 0000000..c244d2d
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/android/desugar/DesugarRuleTest.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright 2019 The Bazel Authors. All rights reserved.
+ *
+ * Licensed 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.
+ */
+
+package com.google.devtools.build.android.desugar;
+
+import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.devtools.build.android.desugar.DesugarRule.LoadClass;
+import java.lang.invoke.MethodHandles;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** The test for {@link DesugarRule}. */
+@RunWith(JUnit4.class)
+public class DesugarRuleTest {
+
+ @Rule
+ public final DesugarRule desugarRule =
+ DesugarRule.builder(this, MethodHandles.lookup())
+ .addRuntimeInputs(
+ "third_party/bazel/src/test/java/com/google/devtools/build/android/desugar/libdesugar_rule_test_target.jar")
+ .build();
+
+ @LoadClass(
+ "com.google.devtools.build.android.desugar.DesugarRuleTestTarget$InterfaceSubjectToDesugar")
+ private Class<?> interfaceSubjectToDesugarClass;
+
+ @LoadClass(
+ "com.google.devtools.build.android.desugar.DesugarRuleTestTarget$InterfaceSubjectToDesugar$$CC")
+ private Class<?> interfaceSubjectToDesugarCompanionClass;
+
+ @Test
+ public void staticMethodsAreMovedFromOriginatingClass() {
+ assertThrows(
+ NoSuchMethodException.class,
+ () -> interfaceSubjectToDesugarClass.getDeclaredMethod("staticMethod"));
+ }
+
+ @Test
+ public void staticMethodsAreMovedToCompanionClass() {
+ assertThat(
+ Arrays.stream(interfaceSubjectToDesugarCompanionClass.getDeclaredMethods())
+ .map(Method::getName))
+ .contains("staticMethod$$STATIC$$");
+ }
+}
diff --git a/src/test/java/com/google/devtools/build/android/desugar/DesugarRuleTestTarget.java b/src/test/java/com/google/devtools/build/android/desugar/DesugarRuleTestTarget.java
new file mode 100644
index 0000000..e82b8e3
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/android/desugar/DesugarRuleTestTarget.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2019 The Bazel Authors. All rights reserved.
+ *
+ * Licensed 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.
+ */
+
+package com.google.devtools.build.android.desugar;
+
+/**
+ * The source class used as data in {@link DesugarRuleTest}.
+ *
+ * <p>Note: This class is used for checking the desugar pipeline and {@link DesugarRule} working as
+ * expected. DO NOT use this target for testing individual desugar logic.
+ */
+@SuppressWarnings({"PrivateConstructorForUtilityClass", "InterfaceWithOnlyStatics"}) // testing-only
+class DesugarRuleTestTarget {
+ interface InterfaceSubjectToDesugar {
+ static void staticMethod() {}
+ }
+}