blob: 299673f3cb780af851a652c9ad4691f674aad6c4 [file] [log] [blame]
"""Build step class for processing jacoco exec files."""
import collections
import json
import logging
import os
import pathlib
import zipfile
import xml.etree.ElementTree as ET
from slave.step import cast_shell_step
COVERAGE_SCRIPT_PATH = 'build/android/generate_jacoco_report.py'
SOURCES_JSON_SUFFIX = '__jacoco_sources.json'
MAPPINGS_FILE = 'jcov_file_mappings.json'
class JacocoCoverageProcessingStep(cast_shell_step.NonOtaBuildBaseStep):
"""Build step class to process jacoco code coverage data."""
def __init__(
self,
coverage_dir,
report_out_dir='report',
report_format='xml',
build_args_product=None,
build_args_official=None,
**kwargs,
):
"""Creates a JacocoCoverageProcessingStep instance.
Args:
coverage_dir: Directory under the build directory that contains the
jacoco exec files.
report_out_dir: Directory under the gcs directory, to output coverage
report.
build_args_product: The name of one of the
chromecast/internal/build/args/product files to use for the GN args.
report_format: Output format of the jacoco coverage report
('html', 'xml', 'csv').
build_args_official: Whether the build is an official build. MUST be set
for any build that will ship to users. Disables debugging; do not
set for development builds.
**kwargs: Any additional args to pass to BaseStep.
"""
super().__init__(
'process jacoco code coverage data.',
build_args_product,
build_args_official,
**kwargs,
)
self._coverage_dir = coverage_dir
self._report_out_dir = report_out_dir
self._report_format = report_format
assert report_format in ['xml', 'html', 'csv']
def get_output_report_path(self):
if self._report_format == 'xml':
logging.info('gcs dir:%s', self.get_gcs_dir())
path = (
pathlib.Path(self.get_gcs_dir())
/ self._report_out_dir
/ 'processed_report.xml'
)
return path
def _remap_coverage_report_paths(self, input_xml_report, output_xml_report):
"""Creates a new xml file which has the file path as in the fs.
Args:
input_xml_report: path to xml report.
output_xml_report: path to write the procesed xml report.
"""
jacoco_xml = ET.parse(input_xml_report)
mapped_files = set()
for root, _, files in os.walk(self._outdir, followlinks=True):
for file in files:
if file.endswith(SOURCES_JSON_SUFFIX):
file_path = os.path.join(root, file)
mapped_files = mapped_files.union(
self._get_mappings_from_sources_json(file_path, jacoco_xml)
)
self._update_coverage_report_xml(
mapped_files,
jacoco_xml,
output_xml_report,
)
def _update_coverage_report_xml(
self,
source_to_workspace_path_mapping,
report_contents,
output_report_path,
):
root = report_contents.getroot()
new_packages = collections.defaultdict(lambda: ET.Element('package'))
new_root = ET.Element('report')
new_tree = ET.ElementTree(new_root)
for file_mapping in source_to_workspace_path_mapping:
pkg_name, file_name, real_path = file_mapping
real_package_path = str(pathlib.Path(real_path).parent)
file_element = root.find(
f'.//package[@name="{pkg_name}"]//sourcefile[@name="{file_name}"]'
)
if file_element is None:
continue
new_packages[real_package_path].set('name', real_package_path)
new_packages[real_package_path].append(file_element)
for new_package in new_packages.values():
new_root.append(new_package)
new_tree.write(output_report_path, encoding='utf-8')
def _get_mappings_from_sources_json(self, sources_json_file, jacoco_xml):
"""Maps the files to their real paths.
Args:
sources_json_file: path to sources json file.
jacoco_xml: object for parsing xml report.
"""
with open(sources_json_file) as file:
parsed_json = json.load(file)
mapped_files = set()
src_dirs_sorted = sorted(parsed_json['source_dirs'], key=len)
for jar_file in parsed_json['input_path']:
with zipfile.ZipFile(jar_file, 'r') as zf:
for java_class_file in zf.infolist():
package, sourcefile = self._get_filepath_from_classpath(
java_class_file.filename, jacoco_xml)
if package and sourcefile:
real_path = self._get_path_to_file_from_src_dirs(
src_dirs_sorted, package, sourcefile)
if real_path:
mapped_files.add((package, sourcefile, real_path))
return mapped_files
def _get_filepath_from_classpath(self, class_file, jacoco_xml):
"""Given the class file name and the jacoco_xml object, this function
returns the package name and the filename of the class.
Args:
class_file: filepath to a class relative to the JAR's root.
jacoco_xml: the parsed Jacoco XML report.
"""
class_name = class_file.split('.')[0]
package = self._get_package_from_class_file(class_file)
root = jacoco_xml.getroot()
class_element = root.find(f'.//class[@name="{class_name}"]')
# For some reason, the bool value of some of the element objects are
# `False`.
if class_element is not None:
filename = class_element.attrib['sourcefilename']
return package, filename
return None, None
def _get_package_from_class_file(self, class_file):
"""Given the class file name, this function returns the package name.
Args:
class_file: name of class file.
"""
return class_file.rsplit('/', 1)[0]
def _get_path_to_file_from_src_dirs(self, src_dirs, pkg_name, sourcefile):
"""Returns the path to the file, given the `source_dirs` list from the
sources json file, the name of the package, and the sourcefile name.
Args:
src_dirs: `source_dirs` list from sources json file. src_dirs should be
sorted by length of package to avoid issues with overlapping
package names.
pkg_name: name of package.
sourcefile: name of source file.
"""
for src_dir in src_dirs:
if src_dir.endswith(pkg_name):
path = os.path.join('chromium/src', src_dir, sourcefile)
# Resolve symlinks.
path = os.path.join(self._getcwd(), path)
path = os.path.realpath(path)
path = os.path.relpath(path, self._getcwd())
return path
return None
def run(self):
"""Runs the generate jacoco report script and generates a coverage report
from the jacoco `exec` files.
Returns:
True iff scripts ran successfuly with no errors.
"""
coverage_script_path = os.path.join(
self.get_project_path('chromium/src'), COVERAGE_SCRIPT_PATH)
vpython3_path = os.path.join(
self.get_project_path('chromium/tools/depot_tools'),
'vpython3',
)
command = [vpython3_path, coverage_script_path]
command += ['--format', self._report_format]
report_path = os.path.join(self.get_gcs_dir(), self._report_out_dir)
# Make sure directory exists.
os.makedirs(report_path, exist_ok=True)
if self._report_format == 'html':
command += ['--output-dir', report_path]
else:
report_file = os.path.join(report_path, f'report.{self._report_format}')
command += ['--output-file', report_file]
coverage_dir_path = os.path.join(self._outdir, self._coverage_dir)
if (not os.path.exists(coverage_dir_path) or
not os.path.isdir(coverage_dir_path) or
not os.listdir(coverage_dir_path)):
logging.info(
'No files found under coverage directory: %s... Not processing '
'coverage files',
coverage_dir_path,
)
return True
command += ['--sources-json-dir', self._outdir]
command += ['--coverage-dir', coverage_dir_path]
command += ['--device-or-host', 'host']
returncode, _, _ = self.exec_subprocess(command)
# Create the mappings file only for xml reports, as this is the only
# format that is accepted by the coverage processor at the moment.
if self._report_format == 'xml':
output_path = self.get_output_report_path()
self._remap_coverage_report_paths(report_file, output_path)
self.set_build_property(
'coverage_jacoco_files',
[str(output_path.relative_to(self.get_gcs_dir()))])
return returncode == 0