blob: 29828e2023121199bc095ad62479965e49afeda2 [file] [log] [blame]
/*
* 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.java;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.log.Logger;
import com.facebook.buck.model.BuildTargets;
import com.facebook.buck.rules.BuildContext;
import com.facebook.buck.rules.BuildRule;
import com.facebook.buck.rules.BuildRuleParams;
import com.facebook.buck.rules.Label;
import com.facebook.buck.rules.RuleKey;
import com.facebook.buck.rules.SourcePath;
import com.facebook.buck.rules.SourcePathResolver;
import com.facebook.buck.rules.TestRule;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.step.TargetDevice;
import com.facebook.buck.step.fs.MakeCleanDirectoryStep;
import com.facebook.buck.test.TestCaseSummary;
import com.facebook.buck.test.TestResultSummary;
import com.facebook.buck.test.TestResults;
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.BuckConstant;
import com.facebook.buck.util.ZipFileTraversal;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Optional;
import com.google.common.base.Preconditions;
import com.google.common.base.Throwables;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Collections;
import java.util.List;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import javax.annotation.Nullable;
@SuppressWarnings("PMD.TestClassWithoutTestCases")
public class JavaTest extends DefaultJavaLibrary implements TestRule {
private final ImmutableList<String> vmArgs;
@Nullable
private CompiledClassFileFinder compiledClassFileFinder;
private final ImmutableSet<Label> labels;
private final ImmutableSet<String> contacts;
private ImmutableSet<BuildRule> sourceUnderTest;
private final ImmutableSet<Path> additionalClasspathEntries;
private final TestType testType;
private final Optional<Long> testRuleTimeoutMs;
private static final int TEST_CLASSES_SHUFFLE_SEED = 0xFACEB00C;
private static final Logger LOG = Logger.get(JavaTest.class);
@Nullable
private JUnitStep junit;
protected JavaTest(
BuildRuleParams params,
SourcePathResolver resolver,
Set<SourcePath> srcs,
Set<SourcePath> resources,
Set<Label> labels,
Set<String> contacts,
Optional<Path> proguardConfig,
ImmutableSet<Path> addtionalClasspathEntries,
TestType testType,
JavacOptions javacOptions,
List<String> vmArgs,
ImmutableSet<BuildRule> sourceUnderTest,
Optional<Path> resourcesRoot,
Optional<Long> testRuleTimeoutMs) {
super(
params,
resolver,
srcs,
resources,
proguardConfig,
ImmutableList.<String>of(),
/* exportDeps */ ImmutableSortedSet.<BuildRule>of(),
/* providedDeps */ ImmutableSortedSet.<BuildRule>of(),
addtionalClasspathEntries,
javacOptions,
resourcesRoot);
this.vmArgs = ImmutableList.copyOf(vmArgs);
this.sourceUnderTest = sourceUnderTest;
this.labels = ImmutableSet.copyOf(labels);
this.contacts = ImmutableSet.copyOf(contacts);
this.additionalClasspathEntries = addtionalClasspathEntries;
this.testType = testType;
this.testRuleTimeoutMs = testRuleTimeoutMs;
}
@Override
public ImmutableSet<Label> getLabels() {
return labels;
}
@Override
public ImmutableSet<String> getContacts() {
return contacts;
}
@Override
public RuleKey.Builder appendDetailsToRuleKey(RuleKey.Builder builder) {
ImmutableSortedSet<? extends BuildRule> srcUnderTest = ImmutableSortedSet.copyOf(
sourceUnderTest);
super.appendDetailsToRuleKey(builder)
.setReflectively("vmArgs", vmArgs)
.setReflectively("sourceUnderTest", srcUnderTest);
return builder;
}
/**
* @return A set of rules that this test rule will be testing.
*/
@Override
public ImmutableSet<BuildRule> getSourceUnderTest() {
return sourceUnderTest;
}
/**
* @param context That may be useful in producing the bootclasspath entries.
*/
protected Set<Path> getBootClasspathEntries(ExecutionContext context) {
return ImmutableSet.of();
}
/**
* 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(
BuildContext buildContext,
ExecutionContext executionContext,
boolean isDryRun,
boolean isShufflingTests,
TestSelectorList testSelectorList) {
// 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(executionContext);
LOG.debug("Testing these classes: %s", testClassNames.toString());
if (testClassNames.isEmpty()) {
return ImmutableList.of();
}
Iterable<String> reorderedTestClasses = reorderClasses(testClassNames, isShufflingTests);
ImmutableList.Builder<Step> steps = ImmutableList.builder();
Path pathToTestOutput = getPathToTestOutputDirectory();
Path tmpDirectory = getPathToTmpDirectory();
steps.add(new MakeCleanDirectoryStep(pathToTestOutput));
steps.add(new MakeCleanDirectoryStep(tmpDirectory));
ImmutableSet<Path> classpathEntries = ImmutableSet.<Path>builder()
.addAll(getTransitiveClasspathEntries().values())
.addAll(additionalClasspathEntries)
.addAll(getBootClasspathEntries(executionContext))
.build();
junit = new JUnitStep(
classpathEntries,
reorderedTestClasses,
amendVmArgs(vmArgs, executionContext.getTargetDeviceOptional()),
pathToTestOutput,
tmpDirectory,
executionContext.isCodeCoverageEnabled(),
executionContext.isDebugEnabled(),
executionContext.getBuckEventBus().getBuildId(),
testSelectorList,
isDryRun,
testType,
testRuleTimeoutMs);
steps.add(junit);
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;
}
@VisibleForTesting
ImmutableList<String> amendVmArgs(
ImmutableList<String> existingVmArgs,
Optional<TargetDevice> targetDevice) {
ImmutableList.Builder<String> vmArgs = ImmutableList.builder();
vmArgs.addAll(existingVmArgs);
onAmendVmArgs(vmArgs, 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,
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.hasIdentifier()) {
vmArgsBuilder.add("-Dbuck.device.id=" + device.getIdentifier());
}
}
@Override
public boolean hasTestResultFiles(ExecutionContext executionContext) {
// 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(executionContext);
if (testClassNames.isEmpty()) {
return true;
}
File outputDirectory = executionContext.getProjectFilesystem().getFileForRelativePath(
getPathToTestOutputDirectory());
for (String testClass : testClassNames) {
// We never use cached results when using test selectors, so there's no need to incorporate
// the .test_selectors suffix here if we are using selectors.
File testResultFile = new File(outputDirectory, testClass + ".xml");
if (!testResultFile.isFile()) {
return false;
}
}
return true;
}
@Override
public Path getPathToTestOutputDirectory() {
List<String> pathsList = Lists.newArrayList();
pathsList.add(getBuildTarget().getBaseNameWithSlash());
pathsList.add(
String.format("__java_test_%s_output__", getBuildTarget().getShortNameAndFlavorPostfix()));
// Putting the one-time test-sub-directory below the usual directory has the nice property that
// doing a test run without "--one-time-output" will tidy up all the old one-time directories!
String subdir = BuckConstant.oneTimeTestSubdirectory;
if (subdir != null && !subdir.isEmpty()) {
pathsList.add(subdir);
}
String[] pathsArray = pathsList.toArray(new String[pathsList.size()]);
return Paths.get(BuckConstant.GEN_DIR, pathsArray);
}
private Path getPathToTmpDirectory() {
Path base = BuildTargets.getBinPath(getBuildTarget(), "__java_test_%s_tmp__").toAbsolutePath();
String subdir = BuckConstant.oneTimeTestSubdirectory;
if (subdir != null && !subdir.isEmpty()) {
base = base.resolve(subdir);
}
return base;
}
/**
* @return a test case result, named "main", signifying a failure of the entire test class.
*/
private TestCaseSummary getTestClassFailedSummary(String testClass, String message) {
return new TestCaseSummary(
testClass,
ImmutableList.of(
new TestResultSummary(
testClass,
"main",
ResultType.FAILURE,
0L,
message,
"",
"",
"")));
}
@Override
public Callable<TestResults> interpretTestResults(
final ExecutionContext context,
final boolean isUsingTestSelectors,
final boolean isDryRun) {
final ImmutableSet<String> contacts = getContacts();
return new Callable<TestResults>() {
@Override
public TestResults call() throws Exception {
// 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(context);
if (testClassNames.isEmpty()) {
return new TestResults(getBuildTarget(), ImmutableList.<TestCaseSummary>of(), contacts);
}
List<TestCaseSummary> summaries = Lists.newArrayListWithCapacity(testClassNames.size());
ProjectFilesystem filesystem = context.getProjectFilesystem();
for (String testClass : testClassNames) {
String testSelectorSuffix = "";
if (isUsingTestSelectors) {
testSelectorSuffix += ".test_selectors";
}
if (isDryRun) {
testSelectorSuffix += ".dry_run";
}
String path = String.format("%s%s.xml", testClass, testSelectorSuffix);
File testResultFile = filesystem.getFileForRelativePath(
getPathToTestOutputDirectory().resolve(path));
if (!isUsingTestSelectors && !testResultFile.isFile()) {
String message;
if (Preconditions.checkNotNull(junit).hasTimedOut()) {
message = "test timed out before generating results file";
} else {
message = "test exited before generating results file";
}
summaries.add(
getTestClassFailedSummary(
testClass,
message));
// 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 (testResultFile.isFile()) {
summaries.add(XmlTestResultParser.parse(testResultFile));
}
}
return new TestResults(getBuildTarget(), summaries, contacts);
}
};
}
private Set<String> getClassNamesForSources(ExecutionContext context) {
if (compiledClassFileFinder == null) {
compiledClassFileFinder = new CompiledClassFileFinder(this, context);
}
return compiledClassFileFinder.getClassNamesForSources();
}
@VisibleForTesting
static class CompiledClassFileFinder {
private final Set<String> classNamesForSources;
CompiledClassFileFinder(JavaTest rule, ExecutionContext context) {
Path outputPath;
Path relativeOutputPath = rule.getPathToOutputFile();
if (relativeOutputPath != null) {
outputPath = context.getProjectFilesystem().getAbsolutifier().apply(relativeOutputPath);
} else {
outputPath = null;
}
classNamesForSources = getClassNamesForSources(
rule.getJavaSrcs(),
outputPath,
context.getProjectFilesystem());
}
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 Set<String> getClassNamesForSources(
Set<Path> sources,
@Nullable Path jarFilePath,
ProjectFilesystem projectFilesystem) {
if (jarFilePath == null) {
return ImmutableSet.of();
}
final Set<String> sourceClassNames = Sets.newHashSetWithExpectedSize(sources.size());
for (Path path : sources) {
String source = path.toString();
int lastSlashIndex = source.lastIndexOf(File.separatorChar);
if (lastSlashIndex >= 0) {
source = source.substring(lastSlashIndex + 1);
}
source = source.substring(0, source.length() - ".java".length());
sourceClassNames.add(source);
}
final ImmutableSet.Builder<String> testClassNames = ImmutableSet.builder();
File jarFile = projectFilesystem.getFileForRelativePath(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 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 Throwables.propagate(e);
}
return testClassNames.build();
}
}
}