/*
* Copyright 2012-present Facebook, Inc.
*
* 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.facebook.buck.jvm.java;
import com.facebook.buck.event.BuckEventBus;
import com.facebook.buck.io.MorePaths;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.log.Logger;
import com.facebook.buck.model.BuildId;
import com.facebook.buck.model.BuildTarget;
import com.facebook.buck.model.BuildTargets;
import com.facebook.buck.model.Either;
import com.facebook.buck.model.Flavor;
import com.facebook.buck.model.InternalFlavor;
import com.facebook.buck.rules.AbstractBuildRuleWithResolver;
import com.facebook.buck.rules.AddToRuleKey;
import com.facebook.buck.rules.BuildContext;
import com.facebook.buck.rules.BuildRule;
import com.facebook.buck.rules.BuildRuleParams;
import com.facebook.buck.rules.BuildableContext;
import com.facebook.buck.rules.ExportDependencies;
import com.facebook.buck.rules.ExternalTestRunnerRule;
import com.facebook.buck.rules.ExternalTestRunnerTestSpec;
import com.facebook.buck.rules.ForwardingBuildTargetSourcePath;
import com.facebook.buck.rules.HasPostBuildSteps;
import com.facebook.buck.rules.HasRuntimeDeps;
import com.facebook.buck.rules.SourcePath;
import com.facebook.buck.rules.SourcePathResolver;
import com.facebook.buck.rules.TestRule;
import com.facebook.buck.step.AbstractExecutionStep;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.step.StepExecutionResult;
import com.facebook.buck.step.TargetDevice;
import com.facebook.buck.step.fs.MakeCleanDirectoryStep;
import com.facebook.buck.step.fs.MkdirStep;
import com.facebook.buck.test.TestCaseSummary;
import com.facebook.buck.test.TestResultSummary;
import com.facebook.buck.test.TestResults;
import com.facebook.buck.test.TestRunningOptions;
import com.facebook.buck.test.XmlTestResultParser;
import com.facebook.buck.test.result.type.ResultType;
import com.facebook.buck.test.selectors.TestSelectorList;
import com.facebook.buck.util.MoreCollectors;
import com.facebook.buck.util.ZipFileTraversal;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.logging.Level;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.annotation.Nullable;
@SuppressWarnings("PMD.TestClassWithoutTestCases")
public class JavaTest extends AbstractBuildRuleWithResolver
implements TestRule,
HasClasspathEntries,
HasRuntimeDeps,
HasPostBuildSteps,
ExternalTestRunnerRule,
ExportDependencies {
public static final Flavor COMPILED_TESTS_LIBRARY_FLAVOR = InternalFlavor.of("testsjar");
// TODO(#9027062): Migrate this to a PackagedResource so we don't make assumptions
// about the ant build.
private static final Path TESTRUNNER_CLASSES =
Paths.get(
System.getProperty(
"buck.testrunner_classes", new File("build/testrunner/classes").getAbsolutePath()));
private final JavaLibrary compiledTestsLibrary;
private final ImmutableSet<Either<SourcePath, Path>> additionalClasspathEntries;
@AddToRuleKey private final JavaRuntimeLauncher javaRuntimeLauncher;
@AddToRuleKey private final ImmutableList<String> vmArgs;
private final ImmutableMap<String, String> nativeLibsEnvironment;
@Nullable private CompiledClassFileFinder compiledClassFileFinder;
private final ImmutableSet<String> labels;
private final ImmutableSet<String> contacts;
private final Optional<Level> stdOutLogLevel;
private final Optional<Level> stdErrLogLevel;
@AddToRuleKey private final TestType testType;
@AddToRuleKey private final Optional<Long> testRuleTimeoutMs;
@AddToRuleKey private final Optional<Long> testCaseTimeoutMs;
@AddToRuleKey private final ImmutableMap<String, String> env;
private final Path pathToTestLogs;
private static final int TEST_CLASSES_SHUFFLE_SEED = 0xFACEB00C;
private static final Logger LOG = Logger.get(JavaTest.class);
@Nullable private ImmutableList<JUnitStep> junits;
@AddToRuleKey private final boolean runTestSeparately;
@AddToRuleKey private final ForkMode forkMode;
public JavaTest(
BuildRuleParams params,
SourcePathResolver resolver,
JavaLibrary compiledTestsLibrary,
ImmutableSet<Either<SourcePath, Path>> additionalClasspathEntries,
Set<String> labels,
Set<String> contacts,
TestType testType,
JavaRuntimeLauncher javaRuntimeLauncher,
List<String> vmArgs,
Map<String, String> nativeLibsEnvironment,
Optional<Long> testRuleTimeoutMs,
Optional<Long> testCaseTimeoutMs,
ImmutableMap<String, String> env,
boolean runTestSeparately,
ForkMode forkMode,
Optional<Level> stdOutLogLevel,
Optional<Level> stdErrLogLevel) {
super(params, resolver);
this.compiledTestsLibrary = compiledTestsLibrary;
for (Either<SourcePath, Path> path : additionalClasspathEntries) {
if (path.isRight()) {
Preconditions.checkState(
path.getRight().isAbsolute(),
"Additional classpath entries must be absolute but got %s",
path.getRight());
}
}
this.additionalClasspathEntries = additionalClasspathEntries;
this.javaRuntimeLauncher = javaRuntimeLauncher;
this.vmArgs = ImmutableList.copyOf(vmArgs);
this.nativeLibsEnvironment = ImmutableMap.copyOf(nativeLibsEnvironment);
this.labels = ImmutableSet.copyOf(labels);
this.contacts = ImmutableSet.copyOf(contacts);
this.testType = testType;
this.testRuleTimeoutMs = testRuleTimeoutMs;
this.testCaseTimeoutMs = testCaseTimeoutMs;
this.env = env;
this.runTestSeparately = runTestSeparately;
this.forkMode = forkMode;
this.stdOutLogLevel = stdOutLogLevel;
this.stdErrLogLevel = stdErrLogLevel;
this.pathToTestLogs = getPathToTestOutputDirectory().resolve("logs.txt");
}
@Override
public ImmutableSet<String> getLabels() {
return labels;
}
@Override
public ImmutableSet<String> getContacts() {
return contacts;
}
/** @param context That may be useful in producing the bootclasspath entries. */
protected ImmutableSet<Path> getBootClasspathEntries(ExecutionContext context) {
return ImmutableSet.of();
}
private Path getClassPathFile() {
return BuildTargets.getGenPath(getProjectFilesystem(), getBuildTarget(), "%s/classpath-file");
}
private JUnitStep getJUnitStep(
ExecutionContext executionContext,
SourcePathResolver pathResolver,
TestRunningOptions options,
Optional<Path> outDir,
Optional<Path> robolectricLogPath,
Set<String> testClassNames) {
Iterable<String> reorderedTestClasses =
reorderClasses(testClassNames, options.isShufflingTests());
ImmutableList<String> properVmArgs =
amendVmArgs(this.vmArgs, pathResolver, executionContext.getTargetDevice());
BuckEventBus buckEventBus = executionContext.getBuckEventBus();
BuildId buildId = buckEventBus.getBuildId();
TestSelectorList testSelectorList = options.getTestSelectorList();
JUnitJvmArgs args =
JUnitJvmArgs.builder()
.setTestType(testType)
.setDirectoryForTestResults(outDir)
.setClasspathFile(getClassPathFile())
.setTestRunnerClasspath(TESTRUNNER_CLASSES)
.setCodeCoverageEnabled(executionContext.isCodeCoverageEnabled())
.setInclNoLocationClassesEnabled(executionContext.isInclNoLocationClassesEnabled())
.setDebugEnabled(executionContext.isDebugEnabled())
.setPathToJavaAgent(options.getPathToJavaAgent())
.setBuildId(buildId)
.setBuckModuleBaseSourceCodePath(getBuildTarget().getBasePath())
.setStdOutLogLevel(stdOutLogLevel)
.setStdErrLogLevel(stdErrLogLevel)
.setRobolectricLogPath(robolectricLogPath)
.setExtraJvmArgs(properVmArgs)
.addAllTestClasses(reorderedTestClasses)
.setShouldExplainTestSelectorList(options.shouldExplainTestSelectorList())
.setTestSelectorList(testSelectorList)
.build();
return new JUnitStep(
getProjectFilesystem(),
nativeLibsEnvironment,
testRuleTimeoutMs,
testCaseTimeoutMs,
env,
javaRuntimeLauncher,
args);
}
/** Returns the underlying java library containing the compiled tests. */
public JavaLibrary getCompiledTestsLibrary() {
return compiledTestsLibrary;
}
/**
* Runs the tests specified by the "srcs" of this class. If this rule transitively depends on
* other {@code java_test()} rules, then they will be run separately.
*/
@Override
public ImmutableList<Step> runTests(
ExecutionContext executionContext,
TestRunningOptions options,
SourcePathResolver pathResolver,
TestReportingCallback testReportingCallback) {
// If no classes were generated, then this is probably a java_test() that declares a number of
// other java_test() rules as deps, functioning as a test suite. In this case, simply return an
// empty list of commands.
Set<String> testClassNames = getClassNamesForSources(pathResolver);
LOG.debug("Testing these classes: %s", testClassNames.toString());
if (testClassNames.isEmpty()) {
return ImmutableList.of();
}
ImmutableList.Builder<Step> steps = ImmutableList.builder();
Path pathToTestOutput = getPathToTestOutputDirectory();
steps.addAll(MakeCleanDirectoryStep.of(getProjectFilesystem(), pathToTestOutput));
if (forkMode() == ForkMode.PER_TEST) {
ImmutableList.Builder<JUnitStep> junitsBuilder = ImmutableList.builder();
for (String testClass : testClassNames) {
junitsBuilder.add(
getJUnitStep(
executionContext,
pathResolver,
options,
Optional.of(pathToTestOutput),
Optional.of(pathToTestLogs),
Collections.singleton(testClass)));
}
junits = junitsBuilder.build();
} else {
junits =
ImmutableList.of(
getJUnitStep(
executionContext,
pathResolver,
options,
Optional.of(pathToTestOutput),
Optional.of(pathToTestLogs),
testClassNames));
}
steps.addAll(junits);
return steps.build();
}
private static Iterable<String> reorderClasses(Set<String> testClassNames, boolean shuffle) {
Random rng;
if (shuffle) {
// This is a runtime-seed reorder, which always produces a new order.
rng = new Random(System.nanoTime());
} else {
// This is fixed-seed reorder, which always produces the same order.
// We still want to do this in order to decouple the test order from the
// filesystem/environment.
rng = new Random(TEST_CLASSES_SHUFFLE_SEED);
}
List<String> reorderedClassNames = Lists.newArrayList(testClassNames);
Collections.shuffle(reorderedClassNames, rng);
return reorderedClassNames;
}
ImmutableList<String> amendVmArgs(
ImmutableList<String> existingVmArgs,
SourcePathResolver pathResolver,
Optional<TargetDevice> targetDevice) {
ImmutableList.Builder<String> vmArgs = ImmutableList.builder();
vmArgs.addAll(existingVmArgs);
onAmendVmArgs(vmArgs, pathResolver, targetDevice);
return vmArgs.build();
}
/**
* Override this method if you need to amend vm args. Subclasses are required to call
* super.onAmendVmArgs(...).
*/
protected void onAmendVmArgs(
ImmutableList.Builder<String> vmArgsBuilder,
@SuppressWarnings("unused") SourcePathResolver pathResolver,
Optional<TargetDevice> targetDevice) {
if (!targetDevice.isPresent()) {
return;
}
TargetDevice device = targetDevice.get();
if (device.isEmulator()) {
vmArgsBuilder.add("-Dbuck.device=emulator");
} else {
vmArgsBuilder.add("-Dbuck.device=device");
}
if (device.getIdentifier().isPresent()) {
vmArgsBuilder.add("-Dbuck.device.id=" + device.getIdentifier().get());
}
}
@Override
public Path getPathToTestOutputDirectory() {
return BuildTargets.getGenPath(
getProjectFilesystem(), getBuildTarget(), "__java_test_%s_output__");
}
/** @return a test case result, named "main", signifying a failure of the entire test class. */
private TestCaseSummary getTestClassFailedSummary(String testClass, String message, long time) {
return new TestCaseSummary(
testClass,
ImmutableList.of(
new TestResultSummary(
testClass, "main", ResultType.FAILURE, time, message, "", "", "")));
}
@Override
public Callable<TestResults> interpretTestResults(
final ExecutionContext context, final boolean isUsingTestSelectors) {
final ImmutableSet<String> contacts = getContacts();
return () -> {
// It is possible that this rule was not responsible for running any tests because all tests
// were run by its deps. In this case, return an empty TestResults.
Set<String> testClassNames = getClassNamesForSources(getResolver());
if (testClassNames.isEmpty()) {
return TestResults.of(
getBuildTarget(),
ImmutableList.of(),
contacts,
labels.stream().map(Object::toString).collect(MoreCollectors.toImmutableSet()));
}
List<TestCaseSummary> summaries = Lists.newArrayListWithCapacity(testClassNames.size());
for (String testClass : testClassNames) {
String testSelectorSuffix = "";
if (isUsingTestSelectors) {
testSelectorSuffix += ".test_selectors";
}
String path = String.format("%s%s.xml", testClass, testSelectorSuffix);
Path testResultFile =
getProjectFilesystem()
.getPathForRelativePath(getPathToTestOutputDirectory().resolve(path));
if (!isUsingTestSelectors && !Files.isRegularFile(testResultFile)) {
String message;
for (JUnitStep junit : Preconditions.checkNotNull(junits)) {
if (junit.hasTimedOut()) {
message = "test timed out before generating results file";
} else {
message = "test exited before generating results file";
}
summaries.add(
getTestClassFailedSummary(testClass, message, testRuleTimeoutMs.orElse(0L)));
}
// Not having a test result file at all (which only happens when we are using test
// selectors) is interpreted as meaning a test didn't run at all, so we'll completely
// ignore it. This is another result of the fact that JUnit is the only thing that can
// definitively say whether or not a class should be run. It's not possible, for example,
// to filter testClassNames here at the buck end.
} else if (Files.isRegularFile(testResultFile)) {
summaries.add(XmlTestResultParser.parse(testResultFile));
}
}
return TestResults.builder()
.setBuildTarget(getBuildTarget())
.setTestCases(summaries)
.setContacts(contacts)
.setLabels(labels.stream().map(Object::toString).collect(MoreCollectors.toImmutableSet()))
.addTestLogPaths(getProjectFilesystem().resolve(pathToTestLogs))
.build();
};
}
private Set<String> getClassNamesForSources(SourcePathResolver pathResolver) {
if (compiledClassFileFinder == null) {
compiledClassFileFinder = new CompiledClassFileFinder(this, pathResolver);
}
return compiledClassFileFinder.getClassNamesForSources();
}
@Override
public ImmutableList<Step> getBuildSteps(
BuildContext context, BuildableContext buildableContext) {
// Nothing to build, this is a test-only rule
return ImmutableList.of();
}
@Nullable
@Override
public SourcePath getSourcePathToOutput() {
SourcePath output = compiledTestsLibrary.getSourcePathToOutput();
if (output == null) {
return null;
}
return new ForwardingBuildTargetSourcePath(getBuildTarget(), output);
}
@Override
public ImmutableSet<SourcePath> getTransitiveClasspaths() {
return compiledTestsLibrary.getTransitiveClasspaths();
}
@Override
public ImmutableSet<JavaLibrary> getTransitiveClasspathDeps() {
return compiledTestsLibrary.getTransitiveClasspathDeps();
}
@Override
public ImmutableSet<SourcePath> getImmediateClasspaths() {
return compiledTestsLibrary.getImmediateClasspaths();
}
@Override
public ImmutableSet<SourcePath> getOutputClasspaths() {
return compiledTestsLibrary.getOutputClasspaths();
}
@Override
public ImmutableSortedSet<BuildRule> getExportedDeps() {
return ImmutableSortedSet.of(compiledTestsLibrary);
}
@VisibleForTesting
static class CompiledClassFileFinder {
private final Set<String> classNamesForSources;
CompiledClassFileFinder(JavaTest rule, SourcePathResolver pathResolver) {
Path outputPath;
SourcePath outputSourcePath = rule.getSourcePathToOutput();
if (outputSourcePath != null) {
outputPath = pathResolver.getAbsolutePath(outputSourcePath);
} else {
outputPath = null;
}
classNamesForSources =
getClassNamesForSources(
rule.compiledTestsLibrary.getJavaSrcs(),
outputPath,
rule.getProjectFilesystem(),
pathResolver);
}
public Set<String> getClassNamesForSources() {
return classNamesForSources;
}
/**
* When a collection of .java files is compiled into a directory, that directory will have a
* subfolder structure that matches the package structure of the input .java files. In general,
* the .java files will be 1:1 with the .class files with two notable exceptions: (1) There will
* be an additional .class file for each inner/anonymous class generated. These types of classes
* are easy to identify because they will contain a '$' in the name. (2) A .java file that
* defines multiple top-level classes (yes, this can exist:
* http://stackoverflow.com/questions/2336692/java-multiple-class-declarations-in-one-file) will
* generate multiple .class files that do not have '$' in the name. In this method, we perform a
* strict check for (1) and use a heuristic for (2). It is possible to filter out the type (2)
* situation with a stricter check that aligns the package directories of the .java files and
* the .class files, but it is a pain to implement. If this heuristic turns out to be
* insufficient in practice, then we can fix it.
*
* @param sources paths to .java source files that were passed to javac
* @param jarFilePath jar where the generated .class files were written
*/
@VisibleForTesting
static ImmutableSet<String> getClassNamesForSources(
Set<SourcePath> sources,
@Nullable Path jarFilePath,
ProjectFilesystem projectFilesystem,
SourcePathResolver resolver) {
if (jarFilePath == null) {
return ImmutableSet.of();
}
final Set<String> sourceClassNames = Sets.newHashSetWithExpectedSize(sources.size());
for (SourcePath path : sources) {
// We support multiple languages in this rule - the file extension doesn't matter so long
// as the language supports filename == classname.
sourceClassNames.add(MorePaths.getNameWithoutExtension(resolver.getRelativePath(path)));
}
final ImmutableSet.Builder<String> testClassNames = ImmutableSet.builder();
Path jarFile = projectFilesystem.getPathForRelativePath(jarFilePath);
ZipFileTraversal traversal =
new ZipFileTraversal(jarFile) {
@Override
public void visit(ZipFile zipFile, ZipEntry zipEntry) {
final String name = new File(zipEntry.getName()).getName();
// Ignore non-.class files.
if (!name.endsWith(".class")) {
return;
}
// As a heuristic for case (2) as described in the Javadoc, make sure the name of the
// .class file matches the name of a .java/.scala/.xxx file.
String nameWithoutDotClass = name.substring(0, name.length() - ".class".length());
if (!sourceClassNames.contains(nameWithoutDotClass)) {
return;
}
// Make sure it is a .class file that corresponds to a top-level .class file and not an
// inner class.
if (!name.contains("$")) {
String fullyQualifiedNameWithDotClassSuffix = zipEntry.getName().replace('/', '.');
String className =
fullyQualifiedNameWithDotClassSuffix.substring(
0, fullyQualifiedNameWithDotClassSuffix.length() - ".class".length());
testClassNames.add(className);
}
}
};
try {
traversal.traverse();
} catch (IOException e) {
// There's nothing sane to do here. The jar file really should exist.
throw new RuntimeException(e);
}
return testClassNames.build();
}
}
@Override
public boolean runTestSeparately() {
return runTestSeparately;
}
public ForkMode forkMode() {
return forkMode;
}
@Override
public Stream<BuildTarget> getRuntimeDeps() {
return Stream.concat(
// By the end of the build, all the transitive Java library dependencies *must* be
// available on disk, so signal this requirement via the {@link HasRuntimeDeps}
// interface.
compiledTestsLibrary
.getTransitiveClasspathDeps()
.stream()
.filter(rule -> !this.equals(rule)),
// It's possible that the user added some tool as a dependency, so make sure we promote
// this rules first-order deps to runtime deps, so that these potential tools are
// available when this test runs.
compiledTestsLibrary.getBuildDeps().stream())
.map(BuildRule::getBuildTarget);
}
@Override
public boolean supportsStreamingTests() {
return false;
}
@Override
public ExternalTestRunnerTestSpec getExternalTestRunnerSpec(
ExecutionContext executionContext,
TestRunningOptions options,
SourcePathResolver pathResolver) {
JUnitStep jUnitStep =
getJUnitStep(
executionContext,
pathResolver,
options,
Optional.empty(),
Optional.empty(),
getClassNamesForSources(pathResolver));
return ExternalTestRunnerTestSpec.builder()
.setTarget(getBuildTarget())
.setType("junit")
.setCommand(jUnitStep.getShellCommandInternal(executionContext))
.setEnv(jUnitStep.getEnvironmentVariables(executionContext))
.setLabels(getLabels())
.setContacts(getContacts())
.build();
}
@Override
public ImmutableList<Step> getPostBuildSteps(BuildContext buildContext) {
return ImmutableList.<Step>builder()
.add(MkdirStep.of(getProjectFilesystem(), getClassPathFile().getParent()))
.add(
new AbstractExecutionStep("write classpath file") {
@Override
public StepExecutionResult execute(ExecutionContext context) throws IOException {
ImmutableSet<Path> classpathEntries =
ImmutableSet.<Path>builder()
.addAll(
compiledTestsLibrary
.getTransitiveClasspaths()
.stream()
.map(buildContext.getSourcePathResolver()::getAbsolutePath)
.collect(MoreCollectors.toImmutableSet()))
.addAll(
additionalClasspathEntries
.stream()
.map(
e ->
e.isLeft()
? buildContext
.getSourcePathResolver()
.getAbsolutePath(e.getLeft())
: e.getRight())
.collect(MoreCollectors.toImmutableSet()))
.addAll(getBootClasspathEntries(context))
.build();
getProjectFilesystem()
.writeLinesToPath(
Iterables.transform(classpathEntries, Object::toString),
getClassPathFile());
return StepExecutionResult.SUCCESS;
}
})
.build();
}
}