blob: 98e41979d7039a0f7e0e5ee5efa20f9a73ed99bf [file] [log] [blame]
#!/usr/bin/python
# Copyright 2017 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""This script helps to generate code coverage report.
It uses Clang Source-based Code Coverage -
https://clang.llvm.org/docs/SourceBasedCodeCoverage.html
In order to generate code coverage report, you need to first add
"use_clang_coverage=true" GN flag to args.gn file in your build
output directory (e.g. out/coverage).
It is recommended to add "is_component_build=false" flag as well because:
1. It is incompatible with other sanitizer flags (like "is_asan", "is_msan")
and others like "optimize_for_fuzzing".
2. If it is not set explicitly, "is_debug" overrides it to true.
Example usage:
gn gen out/coverage --args='use_clang_coverage=true is_component_build=false'
gclient runhooks
python tools/code_coverage/coverage.py crypto_unittests url_unittests \\
-b out/coverage -o out/report -c 'out/coverage/crypto_unittests' \\
-c 'out/coverage/url_unittests --gtest_filter=URLParser.PathURL' \\
-f url/ -f crypto/
The command above builds crypto_unittests and url_unittests targets and then
runs them with specified command line arguments. For url_unittests, it only
runs the test URLParser.PathURL. The coverage report is filtered to include
only files and sub-directories under url/ and crypto/ directories.
If you want to run tests that try to draw to the screen but don't have a
display connected, you can run tests in headless mode with xvfb.
Sample flow for running a test target with xvfb (e.g. unit_tests):
python tools/code_coverage/coverage.py unit_tests -b out/coverage \\
-o out/report -c 'python testing/xvfb.py out/coverage/unit_tests'
If you are building a fuzz target, you need to add "use_libfuzzer=true" GN
flag as well.
Sample workflow for a fuzz target (e.g. pdfium_fuzzer):
python tools/code_coverage/coverage.py pdfium_fuzzer \\
-b out/coverage -o out/report \\
-c 'out/coverage/pdfium_fuzzer -runs=<runs> <corpus_dir>' \\
-f third_party/pdfium
where:
<corpus_dir> - directory containing samples files for this format.
<runs> - number of times to fuzz target function. Should be 0 when you just
want to see the coverage on corpus and don't want to fuzz at all.
For more options, please refer to tools/code_coverage/coverage.py -h.
"""
from __future__ import print_function
import sys
import argparse
import json
import logging
import os
import subprocess
import urllib2
sys.path.append(
os.path.join(
os.path.dirname(__file__), os.path.pardir, os.path.pardir, 'tools',
'clang', 'scripts'))
import update as clang_update
sys.path.append(
os.path.join(
os.path.dirname(__file__), os.path.pardir, os.path.pardir,
'third_party'))
import jinja2
from collections import defaultdict
# Absolute path to the root of the checkout.
SRC_ROOT_PATH = os.path.abspath(
os.path.join(os.path.dirname(__file__), os.path.pardir, os.path.pardir))
# Absolute path to the code coverage tools binary.
LLVM_BUILD_DIR = clang_update.LLVM_BUILD_DIR
LLVM_COV_PATH = os.path.join(LLVM_BUILD_DIR, 'bin', 'llvm-cov')
LLVM_PROFDATA_PATH = os.path.join(LLVM_BUILD_DIR, 'bin', 'llvm-profdata')
# Build directory, the value is parsed from command line arguments.
BUILD_DIR = None
# Output directory for generated artifacts, the value is parsed from command
# line arguemnts.
OUTPUT_DIR = None
# Default number of jobs used to build when goma is configured and enabled.
DEFAULT_GOMA_JOBS = 100
# Name of the file extension for profraw data files.
PROFRAW_FILE_EXTENSION = 'profraw'
# Name of the final profdata file, and this file needs to be passed to
# "llvm-cov" command in order to call "llvm-cov show" to inspect the
# line-by-line coverage of specific files.
PROFDATA_FILE_NAME = 'coverage.profdata'
# Build arg required for generating code coverage data.
CLANG_COVERAGE_BUILD_ARG = 'use_clang_coverage'
# The default name of the html coverage report for a directory.
DIRECTORY_COVERAGE_HTML_REPORT_NAME = os.extsep.join(['report', 'html'])
# Name of the html index files for different views.
DIRECTORY_VIEW_INDEX_FILE = os.extsep.join(['directory_view_index', 'html'])
COMPONENT_VIEW_INDEX_FILE = os.extsep.join(['component_view_index', 'html'])
FILE_VIEW_INDEX_FILE = os.extsep.join(['file_view_index', 'html'])
# Used to extract a mapping between directories and components.
COMPONENT_MAPPING_URL = 'https://storage.googleapis.com/chromium-owners/component_map.json'
class _CoverageSummary(object):
"""Encapsulates coverage summary representation."""
def __init__(self,
regions_total=0,
regions_covered=0,
functions_total=0,
functions_covered=0,
lines_total=0,
lines_covered=0):
"""Initializes _CoverageSummary object."""
self._summary = {
'regions': {
'total': regions_total,
'covered': regions_covered
},
'functions': {
'total': functions_total,
'covered': functions_covered
},
'lines': {
'total': lines_total,
'covered': lines_covered
}
}
def Get(self):
"""Returns summary as a dictionary."""
return self._summary
def AddSummary(self, other_summary):
"""Adds another summary to this one element-wise."""
for feature in self._summary:
self._summary[feature]['total'] += other_summary.Get()[feature]['total']
self._summary[feature]['covered'] += other_summary.Get()[feature][
'covered']
class _CoverageReportHtmlGenerator(object):
"""Encapsulates coverage html report generation.
The generated html has a table that contains links to other coverage reports.
"""
def __init__(self, output_path, table_entry_type):
"""Initializes _CoverageReportHtmlGenerator object.
Args:
output_path: Path to the html report that will be generated.
table_entry_type: Type of the table entries to be displayed in the table
header. For example: 'Path', 'Component'.
"""
css_file_name = os.extsep.join(['style', 'css'])
css_absolute_path = os.path.abspath(os.path.join(OUTPUT_DIR, css_file_name))
assert os.path.exists(css_absolute_path), (
'css file doesn\'t exit. Please make sure "llvm-cov show -format=html" '
'is called first, and the css file is generated at: "%s"' %
css_absolute_path)
self._css_absolute_path = css_absolute_path
self._output_path = output_path
self._table_entry_type = table_entry_type
self._table_entries = []
self._total_entry = {}
template_dir = os.path.join(
os.path.dirname(os.path.realpath(__file__)), 'html_templates')
jinja_env = jinja2.Environment(
loader=jinja2.FileSystemLoader(template_dir), trim_blocks=True)
self._header_template = jinja_env.get_template('header.html')
self._table_template = jinja_env.get_template('table.html')
self._footer_template = jinja_env.get_template('footer.html')
def AddLinkToAnotherReport(self, html_report_path, name, summary):
"""Adds a link to another html report in this report.
The link to be added is assumed to be an entry in this directory.
"""
# Use relative paths instead of absolute paths to make the generated reports
# portable.
html_report_relative_path = _GetRelativePathToDirectoryOfFile(
html_report_path, self._output_path)
table_entry = self._CreateTableEntryFromCoverageSummary(
summary, html_report_relative_path, name,
os.path.basename(html_report_path) ==
DIRECTORY_COVERAGE_HTML_REPORT_NAME)
self._table_entries.append(table_entry)
def CreateTotalsEntry(self, summary):
"""Creates an entry corresponds to the 'Totals' row in the html report."""
self._total_entry = self._CreateTableEntryFromCoverageSummary(summary)
def _CreateTableEntryFromCoverageSummary(self,
summary,
href=None,
name=None,
is_dir=None):
"""Creates an entry to display in the html report."""
assert (href is None and name is None and is_dir is None) or (
href is not None and name is not None and is_dir is not None), (
'The only scenario when href or name or is_dir can be None is when '
'creating an entry for the Totals row, and in that case, all three '
'attributes must be None.')
entry = {}
if href is not None:
entry['href'] = href
if name is not None:
entry['name'] = name
if is_dir is not None:
entry['is_dir'] = is_dir
summary_dict = summary.Get()
for feature in summary_dict:
if summary_dict[feature]['total'] == 0:
percentage = 0.0
else:
percentage = float(summary_dict[feature]['covered']) / summary_dict[
feature]['total'] * 100
color_class = self._GetColorClass(percentage)
entry[feature] = {
'total': summary_dict[feature]['total'],
'covered': summary_dict[feature]['covered'],
'percentage': '{:6.2f}'.format(percentage),
'color_class': color_class
}
return entry
def _GetColorClass(self, percentage):
"""Returns the css color class based on coverage percentage."""
if percentage >= 0 and percentage < 80:
return 'red'
if percentage >= 80 and percentage < 100:
return 'yellow'
if percentage == 100:
return 'green'
assert False, 'Invalid coverage percentage: "%d"' % percentage
def WriteHtmlCoverageReport(self):
"""Writes html coverage report.
In the report, sub-directories are displayed before files and within each
category, entries are sorted alphabetically.
"""
def EntryCmp(left, right):
"""Compare function for table entries."""
if left['is_dir'] != right['is_dir']:
return -1 if left['is_dir'] == True else 1
return -1 if left['name'] < right['name'] else 1
self._table_entries = sorted(self._table_entries, cmp=EntryCmp)
css_path = os.path.join(OUTPUT_DIR, os.extsep.join(['style', 'css']))
directory_view_path = os.path.join(OUTPUT_DIR, DIRECTORY_VIEW_INDEX_FILE)
component_view_path = os.path.join(OUTPUT_DIR, COMPONENT_VIEW_INDEX_FILE)
file_view_path = os.path.join(OUTPUT_DIR, FILE_VIEW_INDEX_FILE)
html_header = self._header_template.render(
css_path=_GetRelativePathToDirectoryOfFile(css_path, self._output_path),
directory_view_href=_GetRelativePathToDirectoryOfFile(
directory_view_path, self._output_path),
component_view_href=_GetRelativePathToDirectoryOfFile(
component_view_path, self._output_path),
file_view_href=_GetRelativePathToDirectoryOfFile(
file_view_path, self._output_path))
html_table = self._table_template.render(
entries=self._table_entries,
total_entry=self._total_entry,
table_entry_type=self._table_entry_type)
html_footer = self._footer_template.render()
with open(self._output_path, 'w') as html_file:
html_file.write(html_header + html_table + html_footer)
def _GetPlatform():
"""Returns current running platform."""
if sys.platform == 'win32' or sys.platform == 'cygwin':
return 'win'
if sys.platform.startswith('linux'):
return 'linux'
else:
assert sys.platform == 'darwin'
return 'mac'
# TODO(crbug.com/759794): remove this function once tools get included to
# Clang bundle:
# https://chromium-review.googlesource.com/c/chromium/src/+/688221
def DownloadCoverageToolsIfNeeded():
"""Temporary solution to download llvm-profdata and llvm-cov tools."""
def _GetRevisionFromStampFile(stamp_file_path, platform):
"""Returns a pair of revision number by reading the build stamp file.
Args:
stamp_file_path: A path the build stamp file created by
tools/clang/scripts/update.py.
Returns:
A pair of integers represeting the main and sub revision respectively.
"""
if not os.path.exists(stamp_file_path):
return 0, 0
with open(stamp_file_path) as stamp_file:
for stamp_file_line in stamp_file.readlines():
if ',' in stamp_file_line:
package_version, target_os = stamp_file_line.rstrip().split(',')
else:
package_version = stamp_file_line.rstrip()
target_os = ''
if target_os and platform != target_os:
continue
clang_revision_str, clang_sub_revision_str = package_version.split('-')
return int(clang_revision_str), int(clang_sub_revision_str)
assert False, 'Coverage is only supported on target_os - linux, mac.'
platform = _GetPlatform()
clang_revision, clang_sub_revision = _GetRevisionFromStampFile(
clang_update.STAMP_FILE, platform)
coverage_revision_stamp_file = os.path.join(
os.path.dirname(clang_update.STAMP_FILE), 'cr_coverage_revision')
coverage_revision, coverage_sub_revision = _GetRevisionFromStampFile(
coverage_revision_stamp_file, platform)
has_coverage_tools = (
os.path.exists(LLVM_COV_PATH) and os.path.exists(LLVM_PROFDATA_PATH))
if (has_coverage_tools and coverage_revision == clang_revision and
coverage_sub_revision == clang_sub_revision):
# LLVM coverage tools are up to date, bail out.
return clang_revision
package_version = '%d-%d' % (clang_revision, clang_sub_revision)
coverage_tools_file = 'llvm-code-coverage-%s.tgz' % package_version
# The code bellow follows the code from tools/clang/scripts/update.py.
if platform == 'mac':
coverage_tools_url = clang_update.CDS_URL + '/Mac/' + coverage_tools_file
else:
assert platform == 'linux'
coverage_tools_url = (
clang_update.CDS_URL + '/Linux_x64/' + coverage_tools_file)
try:
clang_update.DownloadAndUnpack(coverage_tools_url,
clang_update.LLVM_BUILD_DIR)
logging.info('Coverage tools %s unpacked', package_version)
with open(coverage_revision_stamp_file, 'w') as file_handle:
file_handle.write('%s,%s' % (package_version, platform))
file_handle.write('\n')
except urllib2.URLError:
raise Exception(
'Failed to download coverage tools: %s.' % coverage_tools_url)
def _GeneratePerFileLineByLineCoverageInHtml(binary_paths, profdata_file_path,
filters):
"""Generates per file line-by-line coverage in html using 'llvm-cov show'.
For a file with absolute path /a/b/x.cc, a html report is generated as:
OUTPUT_DIR/coverage/a/b/x.cc.html. An index html file is also generated as:
OUTPUT_DIR/index.html.
Args:
binary_paths: A list of paths to the instrumented binaries.
profdata_file_path: A path to the profdata file.
filters: A list of directories and files to get coverage for.
"""
# llvm-cov show [options] -instr-profile PROFILE BIN [-object BIN,...]
# [[-object BIN]] [SOURCES]
# NOTE: For object files, the first one is specified as a positional argument,
# and the rest are specified as keyword argument.
logging.debug('Generating per file line by line coverage reports using '
'"llvm-cov show" command')
subprocess_cmd = [
LLVM_COV_PATH, 'show', '-format=html',
'-output-dir={}'.format(OUTPUT_DIR),
'-instr-profile={}'.format(profdata_file_path), binary_paths[0]
]
subprocess_cmd.extend(
['-object=' + binary_path for binary_path in binary_paths[1:]])
subprocess_cmd.extend(filters)
subprocess.check_call(subprocess_cmd)
logging.debug('Finished running "llvm-cov show" command')
def _GenerateFileViewHtmlIndexFile(per_file_coverage_summary):
"""Generates html index file for file view."""
file_view_index_file_path = os.path.join(OUTPUT_DIR, FILE_VIEW_INDEX_FILE)
logging.debug('Generating file view html index file as: "%s".',
file_view_index_file_path)
html_generator = _CoverageReportHtmlGenerator(file_view_index_file_path,
'Path')
totals_coverage_summary = _CoverageSummary()
for file_path in per_file_coverage_summary:
totals_coverage_summary.AddSummary(per_file_coverage_summary[file_path])
html_generator.AddLinkToAnotherReport(
_GetCoverageHtmlReportPathForFile(file_path),
os.path.relpath(file_path, SRC_ROOT_PATH),
per_file_coverage_summary[file_path])
html_generator.CreateTotalsEntry(totals_coverage_summary)
html_generator.WriteHtmlCoverageReport()
logging.debug('Finished generating file view html index file.')
def _CalculatePerDirectoryCoverageSummary(per_file_coverage_summary):
"""Calculates per directory coverage summary."""
logging.debug('Calculating per-directory coverage summary')
per_directory_coverage_summary = defaultdict(lambda: _CoverageSummary())
for file_path in per_file_coverage_summary:
summary = per_file_coverage_summary[file_path]
parent_dir = os.path.dirname(file_path)
while True:
per_directory_coverage_summary[parent_dir].AddSummary(summary)
if parent_dir == SRC_ROOT_PATH:
break
parent_dir = os.path.dirname(parent_dir)
logging.debug('Finished calculating per-directory coverage summary')
return per_directory_coverage_summary
def _GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
per_file_coverage_summary):
"""Generates per directory coverage breakdown in html."""
logging.debug('Writing per-directory coverage html reports')
for dir_path in per_directory_coverage_summary:
_GenerateCoverageInHtmlForDirectory(
dir_path, per_directory_coverage_summary, per_file_coverage_summary)
logging.debug('Finished writing per-directory coverage html reports')
def _GenerateCoverageInHtmlForDirectory(
dir_path, per_directory_coverage_summary, per_file_coverage_summary):
"""Generates coverage html report for a single directory."""
html_generator = _CoverageReportHtmlGenerator(
_GetCoverageHtmlReportPathForDirectory(dir_path), 'Path')
for entry_name in os.listdir(dir_path):
entry_path = os.path.normpath(os.path.join(dir_path, entry_name))
if entry_path in per_file_coverage_summary:
entry_html_report_path = _GetCoverageHtmlReportPathForFile(entry_path)
entry_coverage_summary = per_file_coverage_summary[entry_path]
elif entry_path in per_directory_coverage_summary:
entry_html_report_path = _GetCoverageHtmlReportPathForDirectory(
entry_path)
entry_coverage_summary = per_directory_coverage_summary[entry_path]
else:
# Any file without executable lines shouldn't be included into the report.
# For example, OWNER and README.md files.
continue
html_generator.AddLinkToAnotherReport(entry_html_report_path,
os.path.basename(entry_path),
entry_coverage_summary)
html_generator.CreateTotalsEntry(per_directory_coverage_summary[dir_path])
html_generator.WriteHtmlCoverageReport()
def _GenerateDirectoryViewHtmlIndexFile():
"""Generates the html index file for directory view.
Note that the index file is already generated under SRC_ROOT_PATH, so this
file simply redirects to it, and the reason of this extra layer is for
structural consistency with other views.
"""
directory_view_index_file_path = os.path.join(OUTPUT_DIR,
DIRECTORY_VIEW_INDEX_FILE)
logging.debug('Generating directory view html index file as: "%s".',
directory_view_index_file_path)
src_root_html_report_path = _GetCoverageHtmlReportPathForDirectory(
SRC_ROOT_PATH)
_WriteRedirectHtmlFile(directory_view_index_file_path,
src_root_html_report_path)
logging.debug('Finished generating directory view html index file.')
def _CalculatePerComponentCoverageSummary(component_to_directories,
per_directory_coverage_summary):
"""Calculates per component coverage summary."""
logging.debug('Calculating per-component coverage summary')
per_component_coverage_summary = defaultdict(lambda: _CoverageSummary())
for component in component_to_directories:
for directory in component_to_directories[component]:
absolute_directory_path = os.path.abspath(directory)
if absolute_directory_path in per_directory_coverage_summary:
per_component_coverage_summary[component].AddSummary(
per_directory_coverage_summary[absolute_directory_path])
logging.debug('Finished calculating per-component coverage summary')
return per_component_coverage_summary
def _ExtractComponentToDirectoriesMapping():
"""Returns a mapping from components to directories."""
component_mappings = json.load(urllib2.urlopen(COMPONENT_MAPPING_URL))
directory_to_component = component_mappings['dir-to-component']
component_to_directories = defaultdict(list)
for directory in directory_to_component:
component = directory_to_component[directory]
component_to_directories[component].append(directory)
return component_to_directories
def _GeneratePerComponentCoverageInHtml(per_component_coverage_summary,
component_to_directories,
per_directory_coverage_summary):
"""Generates per-component coverage reports in html."""
logging.debug('Writing per-component coverage html reports.')
for component in per_component_coverage_summary:
_GenerateCoverageInHtmlForComponent(
component, per_component_coverage_summary, component_to_directories,
per_directory_coverage_summary)
logging.debug('Finished writing per-component coverage html reports.')
def _GenerateCoverageInHtmlForComponent(
component_name, per_component_coverage_summary, component_to_directories,
per_directory_coverage_summary):
"""Generates coverage html report for a component."""
component_html_report_path = _GetCoverageHtmlReportPathForComponent(
component_name)
component_html_report_dir = os.path.dirname(component_html_report_path)
if not os.path.exists(component_html_report_dir):
os.makedirs(component_html_report_dir)
html_generator = _CoverageReportHtmlGenerator(component_html_report_path,
'Path')
for dir_path in component_to_directories[component_name]:
dir_absolute_path = os.path.abspath(dir_path)
if dir_absolute_path not in per_directory_coverage_summary:
# Any directory without an excercised file shouldn't be included into the
# report.
continue
html_generator.AddLinkToAnotherReport(
_GetCoverageHtmlReportPathForDirectory(dir_path),
os.path.relpath(dir_path, SRC_ROOT_PATH),
per_directory_coverage_summary[dir_absolute_path])
html_generator.CreateTotalsEntry(
per_component_coverage_summary[component_name])
html_generator.WriteHtmlCoverageReport()
def _GenerateComponentViewHtmlIndexFile(per_component_coverage_summary):
"""Generates the html index file for component view."""
component_view_index_file_path = os.path.join(OUTPUT_DIR,
COMPONENT_VIEW_INDEX_FILE)
logging.debug('Generating component view html index file as: "%s".',
component_view_index_file_path)
html_generator = _CoverageReportHtmlGenerator(component_view_index_file_path,
'Component')
totals_coverage_summary = _CoverageSummary()
for component in per_component_coverage_summary:
totals_coverage_summary.AddSummary(
per_component_coverage_summary[component])
html_generator.AddLinkToAnotherReport(
_GetCoverageHtmlReportPathForComponent(component), component,
per_component_coverage_summary[component])
html_generator.CreateTotalsEntry(totals_coverage_summary)
html_generator.WriteHtmlCoverageReport()
logging.debug('Finished generating component view html index file.')
def _OverwriteHtmlReportsIndexFile():
"""Overwrites the root index file to redirect to the default view."""
html_index_file_path = os.path.join(OUTPUT_DIR,
os.extsep.join(['index', 'html']))
directory_view_index_file_path = os.path.join(OUTPUT_DIR,
DIRECTORY_VIEW_INDEX_FILE)
_WriteRedirectHtmlFile(html_index_file_path, directory_view_index_file_path)
def _WriteRedirectHtmlFile(from_html_path, to_html_path):
"""Writes a html file that redirects to another html file."""
to_html_relative_path = _GetRelativePathToDirectoryOfFile(
to_html_path, from_html_path)
content = ("""
<!DOCTYPE html>
<html>
<head>
<!-- HTML meta refresh URL redirection -->
<meta http-equiv="refresh" content="0; url=%s">
</head>
</html>""" % to_html_relative_path)
with open(from_html_path, 'w') as f:
f.write(content)
def _GetCoverageHtmlReportPathForFile(file_path):
"""Given a file path, returns the corresponding html report path."""
assert os.path.isfile(file_path), '"%s" is not a file' % file_path
html_report_path = os.extsep.join([os.path.abspath(file_path), 'html'])
# '+' is used instead of os.path.join because both of them are absolute paths
# and os.path.join ignores the first path.
# TODO(crbug.com/809150): Think of a generic cross platform fix (Windows).
return _GetCoverageReportRootDirPath() + html_report_path
def _GetCoverageHtmlReportPathForDirectory(dir_path):
"""Given a directory path, returns the corresponding html report path."""
assert os.path.isdir(dir_path), '"%s" is not a directory' % dir_path
html_report_path = os.path.join(
os.path.abspath(dir_path), DIRECTORY_COVERAGE_HTML_REPORT_NAME)
# '+' is used instead of os.path.join because both of them are absolute paths
# and os.path.join ignores the first path.
# TODO(crbug.com/809150): Think of a generic cross platform fix (Windows).
return _GetCoverageReportRootDirPath() + html_report_path
def _GetCoverageHtmlReportPathForComponent(component_name):
"""Given a component, returns the corresponding html report path."""
component_file_name = component_name.lower().replace('>', '-')
html_report_name = os.extsep.join([component_file_name, 'html'])
return os.path.join(_GetCoverageReportRootDirPath(), 'components',
html_report_name)
def _GetCoverageReportRootDirPath():
"""The root directory that contains all generated coverage html reports."""
return os.path.join(os.path.abspath(OUTPUT_DIR), 'coverage')
def _CreateCoverageProfileDataForTargets(targets, commands, jobs_count=None):
"""Builds and runs target to generate the coverage profile data.
Args:
targets: A list of targets to build with coverage instrumentation.
commands: A list of commands used to run the targets.
jobs_count: Number of jobs to run in parallel for building. If None, a
default value is derived based on CPUs availability.
Returns:
A relative path to the generated profdata file.
"""
_BuildTargets(targets, jobs_count)
profraw_file_paths = _GetProfileRawDataPathsByExecutingCommands(
targets, commands)
profdata_file_path = _CreateCoverageProfileDataFromProfRawData(
profraw_file_paths)
for profraw_file_path in profraw_file_paths:
os.remove(profraw_file_path)
return profdata_file_path
def _BuildTargets(targets, jobs_count):
"""Builds target with Clang coverage instrumentation.
This function requires current working directory to be the root of checkout.
Args:
targets: A list of targets to build with coverage instrumentation.
jobs_count: Number of jobs to run in parallel for compilation. If None, a
default value is derived based on CPUs availability.
"""
def _IsGomaConfigured():
"""Returns True if goma is enabled in the gn build args.
Returns:
A boolean indicates whether goma is configured for building or not.
"""
build_args = _ParseArgsGnFile()
return 'use_goma' in build_args and build_args['use_goma'] == 'true'
logging.info('Building %s', str(targets))
if jobs_count is None and _IsGomaConfigured():
jobs_count = DEFAULT_GOMA_JOBS
subprocess_cmd = ['ninja', '-C', BUILD_DIR]
if jobs_count is not None:
subprocess_cmd.append('-j' + str(jobs_count))
subprocess_cmd.extend(targets)
subprocess.check_call(subprocess_cmd)
logging.debug('Finished building %s', str(targets))
def _GetProfileRawDataPathsByExecutingCommands(targets, commands):
"""Runs commands and returns the relative paths to the profraw data files.
Args:
targets: A list of targets built with coverage instrumentation.
commands: A list of commands used to run the targets.
Returns:
A list of relative paths to the generated profraw data files.
"""
logging.debug('Executing the test commands')
# Remove existing profraw data files.
for file_or_dir in os.listdir(OUTPUT_DIR):
if file_or_dir.endswith(PROFRAW_FILE_EXTENSION):
os.remove(os.path.join(OUTPUT_DIR, file_or_dir))
# Run all test targets to generate profraw data files.
for target, command in zip(targets, commands):
_ExecuteCommand(target, command)
logging.debug('Finished executing the test commands')
profraw_file_paths = []
for file_or_dir in os.listdir(OUTPUT_DIR):
if file_or_dir.endswith(PROFRAW_FILE_EXTENSION):
profraw_file_paths.append(os.path.join(OUTPUT_DIR, file_or_dir))
# Assert one target/command generates at least one profraw data file.
for target in targets:
assert any(
os.path.basename(profraw_file).startswith(target)
for profraw_file in profraw_file_paths), (
'Running target: %s failed to generate any profraw data file, '
'please make sure the binary exists and is properly instrumented.' %
target)
return profraw_file_paths
def _ExecuteCommand(target, command):
"""Runs a single command and generates a profraw data file.
Args:
target: A target built with coverage instrumentation.
command: A command used to run the target.
"""
# Per Clang "Source-based Code Coverage" doc:
# "%Nm" expands out to the instrumented binary's signature. When this pattern
# is specified, the runtime creates a pool of N raw profiles which are used
# for on-line profile merging. The runtime takes care of selecting a raw
# profile from the pool, locking it, and updating it before the program exits.
# If N is not specified (i.e the pattern is "%m"), it's assumed that N = 1.
# N must be between 1 and 9. The merge pool specifier can only occur once per
# filename pattern.
#
# 4 is chosen because it creates some level of parallelism, but it's not too
# big to consume too much computing resource or disk space.
expected_profraw_file_name = os.extsep.join(
[target, '%4m', PROFRAW_FILE_EXTENSION])
expected_profraw_file_path = os.path.join(OUTPUT_DIR,
expected_profraw_file_name)
output_file_name = os.extsep.join([target + '_output', 'txt'])
output_file_path = os.path.join(OUTPUT_DIR, output_file_name)
logging.info('Running command: "%s", the output is redirected to "%s"',
command, output_file_path)
output = subprocess.check_output(
command.split(), env={'LLVM_PROFILE_FILE': expected_profraw_file_path})
with open(output_file_path, 'w') as output_file:
output_file.write(output)
def _CreateCoverageProfileDataFromProfRawData(profraw_file_paths):
"""Returns a relative path to the profdata file by merging profraw data files.
Args:
profraw_file_paths: A list of relative paths to the profraw data files that
are to be merged.
Returns:
A relative path to the generated profdata file.
Raises:
CalledProcessError: An error occurred merging profraw data files.
"""
logging.info('Creating the coverage profile data file')
logging.debug('Merging profraw files to create profdata file')
profdata_file_path = os.path.join(OUTPUT_DIR, PROFDATA_FILE_NAME)
try:
subprocess_cmd = [
LLVM_PROFDATA_PATH, 'merge', '-o', profdata_file_path, '-sparse=true'
]
subprocess_cmd.extend(profraw_file_paths)
subprocess.check_call(subprocess_cmd)
except subprocess.CalledProcessError as error:
print('Failed to merge profraw files to create profdata file')
raise error
logging.debug('Finished merging profraw files')
logging.info('Code coverage profile data is created as: %s',
profdata_file_path)
return profdata_file_path
def _GeneratePerFileCoverageSummary(binary_paths, profdata_file_path, filters):
"""Generates per file coverage summary using "llvm-cov export" command."""
# llvm-cov export [options] -instr-profile PROFILE BIN [-object BIN,...]
# [[-object BIN]] [SOURCES].
# NOTE: For object files, the first one is specified as a positional argument,
# and the rest are specified as keyword argument.
logging.debug('Generating per-file code coverage summary using "llvm-cov '
'export -summary-only" command')
subprocess_cmd = [
LLVM_COV_PATH, 'export', '-summary-only',
'-instr-profile=' + profdata_file_path, binary_paths[0]
]
subprocess_cmd.extend(
['-object=' + binary_path for binary_path in binary_paths[1:]])
subprocess_cmd.extend(filters)
json_output = json.loads(subprocess.check_output(subprocess_cmd))
assert len(json_output['data']) == 1
files_coverage_data = json_output['data'][0]['files']
per_file_coverage_summary = {}
for file_coverage_data in files_coverage_data:
file_path = file_coverage_data['filename']
summary = file_coverage_data['summary']
if summary['lines']['count'] == 0:
continue
per_file_coverage_summary[file_path] = _CoverageSummary(
regions_total=summary['regions']['count'],
regions_covered=summary['regions']['covered'],
functions_total=summary['functions']['count'],
functions_covered=summary['functions']['covered'],
lines_total=summary['lines']['count'],
lines_covered=summary['lines']['covered'])
logging.debug('Finished generating per-file code coverage summary')
return per_file_coverage_summary
def _GetBinaryPath(command):
"""Returns a relative path to the binary to be run by the command.
Currently, following types of commands are supported (e.g. url_unittests):
1. Run test binary direcly: "out/coverage/url_unittests <arguments>"
2. Use xvfb.
2.1. "python testing/xvfb.py out/coverage/url_unittests <arguments>"
2.2. "testing/xvfb.py out/coverage/url_unittests <arguments>"
Args:
command: A command used to run a target.
Returns:
A relative path to the binary.
"""
xvfb_script_name = os.extsep.join(['xvfb', 'py'])
command_parts = command.split()
if os.path.basename(command_parts[0]) == 'python':
assert os.path.basename(command_parts[1]) == xvfb_script_name, (
'This tool doesn\'t understand the command: "%s"' % command)
return command_parts[2]
if os.path.basename(command_parts[0]) == xvfb_script_name:
return command_parts[1]
return command.split()[0]
def _VerifyTargetExecutablesAreInBuildDirectory(commands):
"""Verifies that the target executables specified in the commands are inside
the given build directory."""
for command in commands:
binary_path = _GetBinaryPath(command)
binary_absolute_path = os.path.abspath(os.path.normpath(binary_path))
assert binary_absolute_path.startswith(os.path.abspath(BUILD_DIR)), (
'Target executable "%s" in command: "%s" is outside of '
'the given build directory: "%s".' % (binary_path, command, BUILD_DIR))
def _ValidateBuildingWithClangCoverage():
"""Asserts that targets are built with Clang coverage enabled."""
build_args = _ParseArgsGnFile()
if (CLANG_COVERAGE_BUILD_ARG not in build_args or
build_args[CLANG_COVERAGE_BUILD_ARG] != 'true'):
assert False, ('\'{} = true\' is required in args.gn.'
).format(CLANG_COVERAGE_BUILD_ARG)
def _ParseArgsGnFile():
"""Parses args.gn file and returns results as a dictionary.
Returns:
A dictionary representing the build args.
"""
build_args_path = os.path.join(BUILD_DIR, 'args.gn')
assert os.path.exists(build_args_path), ('"%s" is not a build directory, '
'missing args.gn file.' % BUILD_DIR)
with open(build_args_path) as build_args_file:
build_args_lines = build_args_file.readlines()
build_args = {}
for build_arg_line in build_args_lines:
build_arg_without_comments = build_arg_line.split('#')[0]
key_value_pair = build_arg_without_comments.split('=')
if len(key_value_pair) != 2:
continue
key = key_value_pair[0].strip()
value = key_value_pair[1].strip()
build_args[key] = value
return build_args
def _VerifyPathsAndReturnAbsolutes(paths):
"""Verifies that the paths specified in |paths| exist and returns absolute
versions.
Args:
paths: A list of files or directories.
"""
absolute_paths = []
for path in paths:
absolute_path = os.path.join(SRC_ROOT_PATH, path)
assert os.path.exists(absolute_path), ('Path: "%s" doesn\'t exist.' % path)
absolute_paths.append(absolute_path)
return absolute_paths
def _GetRelativePathToDirectoryOfFile(target_path, base_path):
"""Returns a target path relative to the directory of base_path.
This method requires base_path to be a file, otherwise, one should call
os.path.relpath directly.
"""
assert os.path.dirname(base_path) != base_path, (
'Base path: "%s" is a directory, please call os.path.relpath directly.' %
base_path)
base_dir = os.path.dirname(base_path)
return os.path.relpath(target_path, base_dir)
def _ParseCommandArguments():
"""Adds and parses relevant arguments for tool comands.
Returns:
A dictionary representing the arguments.
"""
arg_parser = argparse.ArgumentParser()
arg_parser.usage = __doc__
arg_parser.add_argument(
'-b',
'--build-dir',
type=str,
required=True,
help='The build directory, the path needs to be relative to the root of '
'the checkout.')
arg_parser.add_argument(
'-o',
'--output-dir',
type=str,
required=True,
help='Output directory for generated artifacts.')
arg_parser.add_argument(
'-c',
'--command',
action='append',
required=True,
help='Commands used to run test targets, one test target needs one and '
'only one command, when specifying commands, one should assume the '
'current working directory is the root of the checkout.')
arg_parser.add_argument(
'-f',
'--filters',
action='append',
required=False,
help='Directories or files to get code coverage for, and all files under '
'the directories are included recursively.')
arg_parser.add_argument(
'-j',
'--jobs',
type=int,
default=None,
help='Run N jobs to build in parallel. If not specified, a default value '
'will be derived based on CPUs availability. Please refer to '
'\'ninja -h\' for more details.')
arg_parser.add_argument(
'-v',
'--verbose',
action='store_true',
help='Prints additional output for diagnostics.')
arg_parser.add_argument(
'-l', '--log_file', type=str, help='Redirects logs to a file.')
arg_parser.add_argument(
'targets', nargs='+', help='The names of the test targets to run.')
args = arg_parser.parse_args()
return args
def Main():
"""Execute tool commands."""
assert _GetPlatform() in [
'linux', 'mac'
], ('Coverage is only supported on linux and mac platforms.')
assert os.path.abspath(os.getcwd()) == SRC_ROOT_PATH, ('This script must be '
'called from the root '
'of checkout.')
DownloadCoverageToolsIfNeeded()
args = _ParseCommandArguments()
global BUILD_DIR
BUILD_DIR = args.build_dir
global OUTPUT_DIR
OUTPUT_DIR = args.output_dir
log_level = logging.DEBUG if args.verbose else logging.INFO
log_format = '[%(asctime)s] %(message)s'
log_file = args.log_file if args.log_file else None
logging.basicConfig(filename=log_file, level=log_level, format=log_format)
assert len(args.targets) == len(args.command), ('Number of targets must be '
'equal to the number of test '
'commands.')
assert os.path.exists(BUILD_DIR), (
'Build directory: {} doesn\'t exist. '
'Please run "gn gen" to generate.').format(BUILD_DIR)
_ValidateBuildingWithClangCoverage()
_VerifyTargetExecutablesAreInBuildDirectory(args.command)
absolute_filter_paths = []
if args.filters:
absolute_filter_paths = _VerifyPathsAndReturnAbsolutes(args.filters)
if not os.path.exists(OUTPUT_DIR):
os.makedirs(OUTPUT_DIR)
profdata_file_path = _CreateCoverageProfileDataForTargets(
args.targets, args.command, args.jobs)
binary_paths = [_GetBinaryPath(command) for command in args.command]
logging.info('Generating code coverage report in html (this can take a while '
'depending on size of target!)')
per_file_coverage_summary = _GeneratePerFileCoverageSummary(
binary_paths, profdata_file_path, absolute_filter_paths)
_GeneratePerFileLineByLineCoverageInHtml(binary_paths, profdata_file_path,
absolute_filter_paths)
_GenerateFileViewHtmlIndexFile(per_file_coverage_summary)
per_directory_coverage_summary = _CalculatePerDirectoryCoverageSummary(
per_file_coverage_summary)
_GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
per_file_coverage_summary)
_GenerateDirectoryViewHtmlIndexFile()
component_to_directories = _ExtractComponentToDirectoriesMapping()
per_component_coverage_summary = _CalculatePerComponentCoverageSummary(
component_to_directories, per_directory_coverage_summary)
_GeneratePerComponentCoverageInHtml(per_component_coverage_summary,
component_to_directories,
per_directory_coverage_summary)
_GenerateComponentViewHtmlIndexFile(per_component_coverage_summary)
# The default index file is generated only for the list of source files, needs
# to overwrite it to display per directory coverage view by default.
_OverwriteHtmlReportsIndexFile()
html_index_file_path = 'file://' + os.path.abspath(
os.path.join(OUTPUT_DIR, 'index.html'))
logging.info('Index file for html report is generated as: %s',
html_index_file_path)
if __name__ == '__main__':
sys.exit(Main())