blob: 12f16e4ecfacf9238984e0053f7da70aa7fa1ad8 [file]
"""Build step class for generating code coverage reports."""
import fileinput
import logging
import os
import pathlib
import shutil
import time
from slave import base_step
from slave.step import cast_shell_step
from cq.scripts.helpers import coverage_utils
COVERAGE_SCRIPT_PATH = 'tools/code_coverage/coverage.py'
COVERAGE_REPORT_PATH = 'code_coverage_html'
COVERAGE_LCOV_PATH = f'{COVERAGE_REPORT_PATH}/linux/coverage.lcov'
STEP_DATA_TEST_LIST = 'step_data_test_list'
# This is a list of test suites to filter out of the coverage
# run. Tests are added to this list of they crash, or otherwise disrupt the
# generation of code coverage data.
TESTS_TO_FILTER_OUT = [
'bindings_browsertests', # b/178190494
'cast_shell_browsertests', # b/178190494
'cast_shell_internal_browsertests', # b/178190494
]
# TODO(b/232528731): Can potentially try to find where this is defined
# dynamically to avoid hardcoding it. If possible,
# use a configuration variable instead.
PREFIX_FILTER = '/b/f/w'
class RunCoverageCommandStep(cast_shell_step.NonOtaBuildBaseStep):
"""Build step class generating code coverage data."""
def __init__(self, branding, build_type, product, build_flavor,
sanitizer=None, build_args_product=None, junit=False,
ignore_filename_regex=None, report_format=None,
build_out_dir=None, test_env=None, **kwargs):
"""Creates a RunCoverageCommandStep instance.
Args:
branding: Build branding (chromium or chrome).
build_type: Type of system to build for ('x86', 'clang', or 'arm').
product: product to build for (ie. 'chromecast', 'audio').
build_flavor: Flavor of build ('Debug', 'Eng', 'Release').
sanitizer: Clang sanitizer to run with ('asan', 'msan', 'tsan', 'ubsan').
build_args_product: The name of one of the
chromecast/internal/build/args/product files to use for the GN args.
junit: If true, run junit tests, otherwise run gtests (Default False).
ignore_filename_regex: Skip source code files with file paths that match
the given regular expression.
report_format: Output format of the "llvm-cov show/export" command
('html', 'text', 'lcov').
build_out_dir: Build output directory.
test_env: Dictionary of env variables to set while executing tests.
**kwargs: Any additional args to pass to BaseStep.
"""
cast_shell_step.NonOtaBuildBaseStep.__init__(
self, 'generate coverage data', branding, build_type,
product, build_flavor, sanitizer=sanitizer, timeout_secs=0,
build_args_product=build_args_product, **kwargs)
self._junit = junit
self._ignore_filename_regex = ignore_filename_regex
self._format = report_format
self._build_out_dir = build_out_dir
self._test_env = test_env
def _get_build_out_dir(self, use_flavor=False):
if self._build_out_dir:
return self._build_out_dir
out_dir = ['out', self.build_type_dir, self.build_product_dir, 'gn']
if self._sanitizer:
out_dir.append(self._sanitizer)
if use_flavor:
return os.path.join('_'.join(out_dir), self.build_flavor_dir)
return '_'.join(out_dir)
def _generate_coverage_args(self, test_list):
"""Generates unit test related arguments for the coverage script.
The coverage script takes in a list of unit test binaries as
an argument, and a corresponding test command for that binary.
This function reads from a list of test commands and constructs
a list of binaries and the command for that binary to be parsed
by coverage.
Args:
test_list: A list where each element represents a test command.
Returns:
A list of binaries and a list of commands arguments.
"""
test_binary_list = []
test_command_list = []
for test_binary_command in test_list:
test_command = test_binary_command.strip().split(' ')
test_name = test_command[0]
if test_name in TESTS_TO_FILTER_OUT:
# Skip filtered tests
continue
test_binary_list.append(test_name)
test_command_list.append('--command')
test_command_list.append(
self._construct_formatted_test_command(test_command))
return test_binary_list, test_command_list
def _construct_formatted_test_command(self, test_command):
"""Formats a test command to be parsed by the coverage script.
Args:
test_command: A list representation of a test command.
Returns:
String argument representation of test command for the coverage script.
"""
test_path = os.path.join(self._get_build_out_dir(use_flavor=True),
test_command[0])
return '{} {}'.format(test_path, ' '.join(test_command[1:]))
def _get_test_list(self):
return self.get_step_data(STEP_DATA_TEST_LIST)
def get_lcov_output_file_paths(self):
return [pathlib.Path(self.get_gcs_dir()) / COVERAGE_LCOV_PATH]
def run(self):
"""Runs the chromium coverage script and generates a report.
Returns:
True iff there were no errors.
"""
# JUnit tests are not supported.
if self._junit:
return True
else:
test_list = self._get_test_list()
test_binary_list, test_command_list = self._generate_coverage_args(
test_list)
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')
report_path = os.path.join(self.get_gcs_dir(), COVERAGE_REPORT_PATH)
command = [vpython3_path, coverage_script_path]
command += test_binary_list
command += [
'-o', report_path, '-b', self._get_build_out_dir(use_flavor=True), '-v']
if self._format:
command += ['--format', self._format]
if self._ignore_filename_regex:
command += ['--ignore-filename-regex', self._ignore_filename_regex]
command += test_command_list
# This is necessary because generating the HTML for code coverage can
# take 3+ hours, in which there is no stdout printed at all. Since a
# build is killed if there is no stdout for 2 hours, this block outputs
# every 1 minute to keep the build alive.
# TODO(kevinmk): Work out a way to have the coverage command output
# something meaningful during the HTML generation step.
subprocess, log_fd, log_file_path = self.exec_non_blocking_subprocess(
command, env=self._test_env)
logging.info('Writing to FD: %d and path %s', log_fd, log_file_path)
while subprocess.poll() is None:
logging.info('[%s] Waiting for completion...',
time.strftime('%Y-%m-%d %H:%M:%S %Z'))
try:
out_files = os.listdir(report_path)
logging.info('Generated Files: %s', str(out_files))
except FileNotFoundError:
pass # Ignore the error if the folder hasn't been created yet.
time.sleep(60)
try:
shutil.copyfile(log_file_path, os.path.join(self.get_gcs_dir(),
'coverage.py.log'))
with open(log_file_path, 'r') as f:
print(f.read())
except IOError:
logging.warning('Log file does not exist.')
return True
class CollectLcovCoverageStep(base_step.BaseStep):
"""Collects and alters the LCOV files for uploading as artifacts."""
def __init__(self, lcov_files, eureka_root, **kwargs):
super().__init__('Collect LCOV Coverage', eureka_root=eureka_root, **kwargs)
self._lcov_files = [pathlib.Path(f) for f in lcov_files]
self._workspace_root = pathlib.Path(eureka_root)
def run(self):
gcs_dir = pathlib.Path(self.get_gcs_dir())
artifact_directory = gcs_dir / 'coverage'
artifact_directory.mkdir(exist_ok=True)
lcov_output_property = []
for lcov_file in self._lcov_files:
output_filename = _transform_file_path(lcov_file)
output_path = artifact_directory / output_filename
logging.info('Copying %s to %s', lcov_file, output_path)
coverage_utils.rewrite_file_paths_in_lcov(
lcov_file, output_path, self._workspace_root)
lcov_output_property.append(str(output_path.relative_to(gcs_dir)))
if lcov_output_property:
self.set_build_property('coverage_lcov_files', lcov_output_property)
return True
def _transform_file_path(file_path):
"""Constructs a unique filename from path."""
return '_'.join(file_path.resolve().relative_to('/').parts)