blob: e1e51531fcb8037f3aa9e10cb46d8c01705c4b6d [file] [log] [blame]
// Copyright 2016 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.testing.coverage;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableList.Builder;
import com.google.common.collect.ImmutableSet;
import com.google.common.io.ByteStreams;
import com.google.common.io.Files;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.jar.Attributes;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.Manifest;
import org.jacoco.agent.rt.IAgent;
import org.jacoco.agent.rt.RT;
import org.jacoco.core.analysis.Analyzer;
import org.jacoco.core.analysis.CoverageBuilder;
import org.jacoco.core.analysis.IBundleCoverage;
import org.jacoco.core.tools.ExecFileLoader;
import org.jacoco.report.IReportVisitor;
import org.jacoco.report.ISourceFileLocator;
import sun.misc.Unsafe;
/**
* Runner class used to generate code coverage report when using Jacoco offline instrumentation.
*
* <p>The complete list of features available for Jacoco offline instrumentation:
* http://www.eclemma.org/jacoco/trunk/doc/offline.html
*
* <p>The structure is roughly following the canonical Jacoco example:
* http://www.eclemma.org/jacoco/trunk/doc/examples/java/ReportGenerator.java
*
* <p>The following environment variables are expected:
* <ul>
* <li>JAVA_COVERAGE_FILE - specifies final location of the generated lcov file.</li>
* <li>JACOCO_METADATA_JAR - specifies jar containing uninstrumented classes to be analyzed.</li>
* </ul>
*/
public class JacocoCoverageRunner {
private final ImmutableList<File> classesJars;
private final InputStream executionData;
private final File reportFile;
private ExecFileLoader execFileLoader;
private HashMap<String, byte[]> uninstrumentedClasses;
private ImmutableSet<String> pathsForCoverage = ImmutableSet.of();
/**
* Creates a new coverage runner extracting the classes jars from a wrapper file. Uses
* javaRunfilesRoot to compute the absolute path of the jars inside the wrapper file.
*/
public JacocoCoverageRunner(
InputStream jacocoExec, String reportPath, File wrapperFile, String javaRunfilesRoot)
throws IOException {
executionData = jacocoExec;
reportFile = new File(reportPath);
this.classesJars = getFilesFromFileList(wrapperFile, javaRunfilesRoot);
}
public JacocoCoverageRunner(InputStream jacocoExec, String reportPath, File... metadataJars) {
executionData = jacocoExec;
reportFile = new File(reportPath);
this.classesJars = ImmutableList.copyOf(metadataJars);
}
public JacocoCoverageRunner(
InputStream jacocoExec,
String reportPath,
HashMap<String, byte[]> uninstrumentedClasses,
ImmutableSet<String> pathsForCoverage,
File... metadataJars) {
executionData = jacocoExec;
reportFile = new File(reportPath);
this.classesJars = ImmutableList.copyOf(metadataJars);
this.uninstrumentedClasses = uninstrumentedClasses;
this.pathsForCoverage = pathsForCoverage;
}
public void create() throws IOException {
// Read the jacoco.exec file. Multiple data files could be merged at this point
execFileLoader = new ExecFileLoader();
execFileLoader.load(executionData);
// Run the structure analyzer on a single class folder or jar file to build up the coverage
// model. Typically you would create a bundle for each class folder and each jar you want in
// your report. If you have more than one bundle you may need to add a grouping node to the
// report. The lcov formatter doesn't seem to care, and we're only using one bundle anyway.
final IBundleCoverage bundleCoverage = analyzeStructure();
final Map<String, BranchCoverageDetail> branchDetails = analyzeBranch();
createReport(bundleCoverage, branchDetails);
}
@VisibleForTesting
void createReport(
final IBundleCoverage bundleCoverage, final Map<String, BranchCoverageDetail> branchDetails)
throws IOException {
JacocoLCOVFormatter formatter = new JacocoLCOVFormatter(createPathsSet());
final IReportVisitor visitor = formatter.createVisitor(reportFile, branchDetails);
// Initialize the report with all of the execution and session information. At this point the
// report doesn't know about the structure of the report being created.
visitor.visitInfo(
execFileLoader.getSessionInfoStore().getInfos(),
execFileLoader.getExecutionDataStore().getContents());
// Populate the report structure with the bundle coverage information.
// Call visitGroup if you need groups in your report.
// Note the API requires a sourceFileLocator because the HTML and XML formatters display a page
// of code annotated with coverage information. Having the source files is not actually needed
// for generating the lcov report...
visitor.visitBundle(
bundleCoverage,
new ISourceFileLocator() {
@Override
public Reader getSourceFile(String packageName, String fileName) throws IOException {
return null;
}
@Override
public int getTabWidth() {
return 0;
}
});
// Signal end of structure information to allow report to write all information out
visitor.visitEnd();
}
@VisibleForTesting
IBundleCoverage analyzeStructure() throws IOException {
final CoverageBuilder coverageBuilder = new CoverageBuilder();
final Analyzer analyzer = new Analyzer(execFileLoader.getExecutionDataStore(), coverageBuilder);
Set<String> alreadyInstrumentedClasses = new HashSet<>();
if (uninstrumentedClasses == null) {
for (File classesJar : classesJars) {
analyzeUninstrumentedClassesFromJar(analyzer, classesJar, alreadyInstrumentedClasses);
}
} else {
for (Map.Entry<String, byte[]> entry : uninstrumentedClasses.entrySet()) {
analyzer.analyzeClass(entry.getValue(), entry.getKey());
}
}
// TODO(bazel-team): Find out where the name of the bundle can pop out in the report.
return coverageBuilder.getBundle("isthisevenused");
}
// Additional pass to process the branch details of the classes
private Map<String, BranchCoverageDetail> analyzeBranch() throws IOException {
final BranchDetailAnalyzer analyzer =
new BranchDetailAnalyzer(execFileLoader.getExecutionDataStore());
Map<String, BranchCoverageDetail> result = new TreeMap<>();
Set<String> alreadyInstrumentedClasses = new HashSet<>();
if (uninstrumentedClasses == null) {
for (File classesJar : classesJars) {
analyzeUninstrumentedClassesFromJar(analyzer, classesJar, alreadyInstrumentedClasses);
result.putAll(analyzer.getBranchDetails());
}
} else {
for (Map.Entry<String, byte[]> entry : uninstrumentedClasses.entrySet()) {
analyzer.analyzeClass(entry.getValue(), entry.getKey());
}
result.putAll(analyzer.getBranchDetails());
}
return result;
}
/**
* Analyzes all uninstrumented class files found in the given jar.
*
* <p>The uninstrumented classes are named using the .class.uninstrumented suffix.
*/
private void analyzeUninstrumentedClassesFromJar(
Analyzer analyzer, File jar, Set<String> alreadyInstrumentedClasses) throws IOException {
JarFile jarFile = new JarFile(jar);
Enumeration<JarEntry> jarFileEntries = jarFile.entries();
while (jarFileEntries.hasMoreElements()) {
JarEntry jarEntry = jarFileEntries.nextElement();
String jarEntryName = jarEntry.getName();
if (jarEntryName.endsWith(".class.uninstrumented")
&& !alreadyInstrumentedClasses.contains(jarEntryName)) {
analyzer.analyzeAll(jarFile.getInputStream(jarEntry), jarEntryName);
alreadyInstrumentedClasses.add(jarEntryName);
}
}
}
/**
* Creates a {@link Set} containing the paths of the covered Java files.
*
* <p>The paths are retrieved from a txt file that is found inside each jar containing
* uninstrumented classes. Each line of the txt file represents a path to be added to the set.
*
* <p>This set is needed by {@link JacocoLCOVFormatter} in order to output the correct path for
* each covered class.
*/
@VisibleForTesting
ImmutableSet<String> createPathsSet() throws IOException {
if (!pathsForCoverage.isEmpty()) {
return pathsForCoverage;
}
ImmutableSet.Builder<String> execPathsSetBuilder = ImmutableSet.builder();
for (File classJar : classesJars) {
addEntriesToExecPathsSet(classJar, execPathsSetBuilder);
}
ImmutableSet<String> result = execPathsSetBuilder.build();
return result;
}
/**
* Adds to the given {@link Set} the paths found in a txt file inside the given jar.
*
* <p>If a jar contains uninstrumented classes it will also contain a txt file with the paths of
* each of these classes, one on each line.
*/
@VisibleForTesting
static void addEntriesToExecPathsSet(
File jar, ImmutableSet.Builder<String> execPathsSetBuilder) throws IOException {
JarFile jarFile = new JarFile(jar);
Enumeration<JarEntry> jarFileEntries = jarFile.entries();
while (jarFileEntries.hasMoreElements()) {
JarEntry jarEntry = jarFileEntries.nextElement();
String jarEntryName = jarEntry.getName();
if (jarEntryName.endsWith("-paths-for-coverage.txt")) {
BufferedReader bufferedReader =
new BufferedReader(new InputStreamReader(jarFile.getInputStream(jarEntry), UTF_8));
String line;
while ((line = bufferedReader.readLine()) != null) {
execPathsSetBuilder.add(line);
}
}
}
}
private static Class<?> getMainClass(boolean insideDeployJar) throws Exception {
Class<?> mainClass;
// If we're running inside a deploy jar we have to open the manifest and read the value of
// "Coverage-Main-Class", set by bazel.
// Note ClassLoader#getResource() will only return the first result, most likely a manifest
// from the bootclasspath.
if (insideDeployJar) {
if (JacocoCoverageRunner.class.getClassLoader() != null) {
Enumeration<URL> manifests =
JacocoCoverageRunner.class.getClassLoader().getResources("META-INF/MANIFEST.MF");
while (manifests.hasMoreElements()) {
Manifest manifest = new Manifest(manifests.nextElement().openStream());
Attributes attributes = manifest.getMainAttributes();
String className = attributes.getValue("Coverage-Main-Class");
if (className != null) {
// Some test frameworks use dummy Coverage-Main-Class in the deploy jars
// which should be ignored by JacocoCoverageRunner.
try {
mainClass = Class.forName(className);
return mainClass;
} catch (ClassNotFoundException e) {
// ignore this class and move on
}
}
}
}
}
// Check JACOCO_MAIN_CLASS after making sure we're not running inside a deploy jar, otherwise
// the deploy jar will be invoked using the wrong main class.
String jacocoMainClass = System.getenv("JACOCO_MAIN_CLASS");
if (jacocoMainClass != null) {
return Class.forName(jacocoMainClass);
}
throw new IllegalStateException(
"JACOCO_METADATA_JAR/JACOCO_MAIN_CLASS environment variables not set, and no"
+ " META-INF/MANIFEST.MF on the classpath has a Coverage-Main-Class attribute. "
+ " Cannot determine the name of the main class for the code under test.");
}
private static String getUniquePath(String pathTemplate, String suffix) throws IOException {
// If pathTemplate is null, we're likely executing from a deploy jar and the test framework
// did not properly set the environment for coverage reporting. This alone is not a reason for
// throwing an exception, we're going to run anyway and write the coverage data to a temporary,
// throw-away file.
if (pathTemplate == null) {
return File.createTempFile("coverage", suffix).getPath();
} else {
// bazel sets the path template to a file with the .dat extension. lcov_merger matches all
// files having '.dat' in their name, so instead of appending we change the extension.
File absolutePathTemplate = new File(pathTemplate).getAbsoluteFile();
String prefix = absolutePathTemplate.getName();
int lastDot = prefix.lastIndexOf('.');
if (lastDot != -1) {
prefix = prefix.substring(0, lastDot);
}
return File.createTempFile(prefix, suffix, absolutePathTemplate.getParentFile()).getPath();
}
}
/**
* Returns an immutable list containing all the file paths found in mainFile. It uses the
* javaRunfilesRoot prefix for every found file to compute its absolute path.
*/
private static ImmutableList<File> getFilesFromFileList(File mainFile, String javaRunfilesRoot)
throws IOException {
List<String> metadataFiles = Files.readLines(mainFile, UTF_8);
ImmutableList.Builder<File> convertedMetadataFiles = new Builder<>();
for (String metadataFile : metadataFiles) {
convertedMetadataFiles.add(new File(javaRunfilesRoot + "/" + metadataFile));
}
return convertedMetadataFiles.build();
}
private static URL[] getUrls(ClassLoader classLoader) {
if (classLoader instanceof URLClassLoader) {
return ((URLClassLoader) classLoader).getURLs();
}
// java 9 and later
if (classLoader.getClass().getName().startsWith("jdk.internal.loader.ClassLoaders$")) {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe) field.get(null);
// jdk.internal.loader.ClassLoaders.AppClassLoader.ucp
Field ucpField = classLoader.getClass().getDeclaredField("ucp");
long ucpFieldOffset = unsafe.objectFieldOffset(ucpField);
Object ucpObject = unsafe.getObject(classLoader, ucpFieldOffset);
// jdk.internal.loader.URLClassPath.path
Field pathField = ucpField.getType().getDeclaredField("path");
long pathFieldOffset = unsafe.objectFieldOffset(pathField);
ArrayList<URL> path = (ArrayList<URL>) unsafe.getObject(ucpObject, pathFieldOffset);
return path.toArray(new URL[path.size()]);
} catch (Exception e) {
return null;
}
}
return null;
}
public static void main(String[] args) throws Exception {
String metadataFile = System.getenv("JACOCO_METADATA_JAR");
File[] metadataFiles = null;
int deployJars = 0;
final HashMap<String, byte[]> uninstrumentedClasses = new HashMap<>();
ImmutableSet.Builder<String> pathsForCoverageBuilder = new ImmutableSet.Builder<>();
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
URL[] urls = getUrls(classLoader);
if (urls != null) {
metadataFiles = new File[urls.length];
for (int i = 0; i < urls.length; i++) {
String file = URLDecoder.decode(urls[i].getFile(), "UTF-8");
metadataFiles[i] = new File(file);
// Special case for when there is only one deploy jar on the classpath.
if (file.endsWith("_deploy.jar")) {
metadataFile = file;
deployJars++;
}
if (file.endsWith(".jar")) {
// Collect
// - uninstrumented class files for coverage before starting the actual test
// - paths considered for coverage
// Collecting these in the shutdown hook is too expensive (we only have a 5s budget).
JarFile jarFile = new JarFile(file);
Enumeration<JarEntry> jarFileEntries = jarFile.entries();
while (jarFileEntries.hasMoreElements()) {
JarEntry jarEntry = jarFileEntries.nextElement();
String jarEntryName = jarEntry.getName();
if (jarEntryName.endsWith(".class.uninstrumented")
&& !uninstrumentedClasses.containsKey(jarEntryName)) {
uninstrumentedClasses.put(
jarEntryName, ByteStreams.toByteArray(jarFile.getInputStream(jarEntry)));
} else if (jarEntryName.endsWith("-paths-for-coverage.txt")) {
BufferedReader bufferedReader =
new BufferedReader(
new InputStreamReader(jarFile.getInputStream(jarEntry), UTF_8));
String line;
while ((line = bufferedReader.readLine()) != null) {
pathsForCoverageBuilder.add(line);
}
}
}
}
}
}
final ImmutableSet<String> pathsForCoverage = pathsForCoverageBuilder.build();
final String metadataFileFinal = metadataFile;
final File[] metadataFilesFinal = metadataFiles;
final String javaRunfilesRoot = System.getenv("JACOCO_JAVA_RUNFILES_ROOT");
boolean hasOneFile = false;
if (metadataFile != null
&& (metadataFile.endsWith("_merged_instr.jar") || metadataFile.endsWith("_deploy.jar"))) {
// bazel can set JACOCO_METADATA_JAR to either one file (a deploy jar
// or a merged jar) or to multiple jars.
hasOneFile = true;
}
final boolean hasOneFileFinal = hasOneFile;
final String coverageReportBase = System.getenv("JAVA_COVERAGE_FILE");
// Disable Jacoco's default output mechanism, which runs as a shutdown hook. We generate the
// report in our own shutdown hook below, and we want to avoid the data race (shutdown hooks are
// not guaranteed any particular order). Note that also by default, Jacoco appends coverage
// data, which can have surprising results if running tests locally or somehow encountering
// the previous .exec file.
System.setProperty("jacoco-agent.output", "none");
// We have no use for this sessionId property, but leaving it blank results in a DNS lookup
// at runtime. A minor annoyance: the documentation insists the property name is "sessionId",
// however on closer inspection of the source code, it turns out to be "sessionid"...
System.setProperty("jacoco-agent.sessionid", "default");
// A JVM shutdown hook has a fixed amount of time (OS-dependent) before it is terminated.
// For our purpose, it's more than enough to scan through the instrumented jar and match up
// the bytecode with the coverage data. It wouldn't be enough for scanning the entire classpath,
// or doing something else terribly inefficient.
Runtime.getRuntime()
.addShutdownHook(
new Thread() {
@Override
public void run() {
try {
// If the test spawns multiple JVMs, they will race to write to the same files. We
// need to generate unique paths for each execution. lcov_merger simply collects
// all the .dat files in the current directory anyway, so we don't need to worry
// about merging them.
String coverageReport = getUniquePath(coverageReportBase, ".dat");
String coverageData = getUniquePath(coverageReportBase, ".exec");
// Get a handle on the Jacoco Agent and write out the coverage data. Other options
// included talking to the agent via TCP (useful when gathering coverage from
// multiple JVMs), or via JMX (the agent's MXBean is called
// 'org.jacoco:type=Runtime'). As we're running in the same JVM, these options
// seemed overkill, we can just refer to the Jacoco runtime as RT.
// See http://www.eclemma.org/jacoco/trunk/doc/agent.html for all the options
// available.
ByteArrayInputStream dataInputStream;
try {
IAgent agent = RT.getAgent();
byte[] data = agent.getExecutionData(false);
try (FileOutputStream fs = new FileOutputStream(coverageData, true)) {
fs.write(data);
}
// We append to the output file, but run report generation only for the coverage
// data from this JVM. The output file may contain data from other
// subprocesses, etc.
dataInputStream = new ByteArrayInputStream(data);
} catch (IllegalStateException e) {
// In this case, we didn't execute a single instrumented file, so the agent
// isn't live. There's no coverage to report, but it's otherwise a successful
// invocation.
dataInputStream = new ByteArrayInputStream(new byte[0]);
}
if (metadataFileFinal != null || metadataFilesFinal != null) {
File[] metadataJars;
if (metadataFilesFinal != null) {
metadataJars = metadataFilesFinal;
} else {
metadataJars =
hasOneFileFinal
? new File[] {new File(metadataFileFinal)}
: getFilesFromFileList(new File(metadataFileFinal), javaRunfilesRoot)
.toArray(new File[0]);
}
if (uninstrumentedClasses.isEmpty()) {
new JacocoCoverageRunner(dataInputStream, coverageReport, metadataJars)
.create();
} else {
new JacocoCoverageRunner(
dataInputStream,
coverageReport,
uninstrumentedClasses,
pathsForCoverage,
metadataJars)
.create();
}
}
} catch (IOException e) {
e.printStackTrace();
Runtime.getRuntime().halt(1);
}
}
});
// If running inside a deploy jar the classpath contains only that deploy jar.
// It can happen that multiple deploy jars are on the classpath. In that case we are running
// from a regular java binary where all the environment (e.g. JACOCO_MAIN_CLASS) is set
// accordingly.
boolean insideDeployJar =
(deployJars == 1) && (metadataFilesFinal == null || metadataFilesFinal.length == 1);
Class<?> mainClass = getMainClass(insideDeployJar);
Method main = mainClass.getMethod("main", String[].class);
main.setAccessible(true);
// Another option would be to run the tests in a separate JVM, let Jacoco dump out the coverage
// data, wait for the subprocess to finish and then generate the lcov report. The only benefit
// of doing this is not being constrained by the hard 5s limit of the shutdown hook. Setting up
// the subprocess to match all JVM flags, runtime classpath, bootclasspath, etc is doable.
// We'd share the same limitation if the system under test uses shutdown hooks internally, as
// there's no way to collect coverage data on that code.
main.invoke(null, new Object[] {args});
}
}