blob: fbde26a190b38c35c6486f48dee6db2f5443e5e2 [file] [log] [blame] [edit]
"""Build step classes for building and running unit tests."""
import logging
import os
import re
import six
from helpers import branch_utils
from helpers import git_utils
from helpers import gn_utils
from helpers import ninja_utils
from slave import base_step
from slave.step import cast_shell_step
from slave.step import sanitizer_unittest_step
from slave.step import shell_step
_BUILD_CHROMECAST_SCRIPT = 'build/build_chromecast.py'
RUN_TEST_LIST_PATH = 'tests/run_test_list.txt'
RUN_JUNIT_TEST_LIST_PATH = 'junit/run_junit_test_list.txt'
STEP_DATA_TEST_LIST = 'step_data_test_list'
STEP_DATA_JUNIT_TEST_LIST = 'step_data_junit_test_list'
ANDROID_TEST_RUNNER_PROJECT = 'test'
ANDROID_TEST_RUNNER_PATH = 'unit_test/atv_unit_test_runner.py'
TRANSFORM_XML_TO_JSON_PATH = 'unit_test/transform_xml_results_to_json.py'
UNIT_TEST_REPORT_PATH = 'unit_test_results'
# This is the maximum number of modified files a single change can have
# before we assume that we need to run all unit tests. This value exists
# because it takes time to calculate which tests need to be run for a set
# of files and for very large changes, like chromium merges, it's just better
# to assume we need to run all tests.
MAX_TARGETS_FOR_ALL_TESTS = 1000
# There are some files that GN does not know about that could affect the
# unit tests. If any of these files are touched, then build and run all the
# unit tests in order to be safe.
# A map of <project-name> -> <list of file regexes to match>.
FORCE_RUN_ALL_TESTS_WHITELIST = {
'chromecast/internal': [r'build/.*', r'vendor/google3/prebuilts.json'],
'vendor/amlogic': [r'google3_prebuilts.json'],
'vendor/marvell': [r'google3_prebuilts.json'],
}
class GnExecutionError(RuntimeError):
"""Exception raised when a gn command returns a non-zero return code."""
class BuildAffectedUnitTestsStep(cast_shell_step.NonOtaBuildBaseStep):
"""Build step class for building unit tests."""
def __init__(self,
sanitizer=None,
code_coverage=False,
absolute_coverage=False,
build_args_product=None,
build_args_official=None,
junit=False,
**kwargs):
"""Instantiate the class.
Args:
sanitizer: Clang saniziter to run with ('asan', 'msan', 'tsan', 'ubsan').
code_coverage: True if code coverage is enabled for this build.
absolute_coverage: True if you want to run coverage for all unit tests.
build_args_product: The name of one of the
chromecast/internal/build/args/product files to use for the GN args.
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.
junit: If True, build junit tests. Otherwise, build gtests (Default False)
**kwargs: Any additional args to pass to BaseStep.
"""
if junit:
step_name = 'build affected junit tests'
else:
step_name = 'build affected gtest unit tests'
cast_shell_step.NonOtaBuildBaseStep.__init__(
self,
step_name,
build_args_product=build_args_product,
build_args_official=build_args_official,
**kwargs)
self._code_coverage = code_coverage
self._absolute_coverage = absolute_coverage
self._junit = junit
self._run_only_affected_tests = (
self.get_property('build_system') != 'catabuilder' and
branch_utils.is_branch_equal_to_or_later_than(self.manifest_branch,
'1.27'))
self._changed_files = None
def BuildUnitTests(self):
archive_dir = self.get_gcs_dir()
build_script = os.path.join(
self.get_project_path('chromecast/internal'), _BUILD_CHROMECAST_SCRIPT)
command = [
'python3', build_script, '--archive_dir', archive_dir,
'--build_args_product', self._build_args_product, '--build_number',
self.build_number, '--chrome_root',
self.get_project_path('chromium/src')
]
command += self.build_accelerator.build_chromecast_flags
# TODO(b/73231972) remove unused flags once migrated to new builds
if self._build_args_official:
command += ['--official']
command += ['--tests_only']
if self.get_property('build_system') == 'catabuilder':
command += ['--enable_gn_check']
if self._junit:
command += ['--use_junit']
if self._code_coverage:
command += ['--enable_code_coverage']
if not self._must_run_all_tests():
# Run `gn gen` to make sure the out folder exists before `gn refs` is run.
returncode, _, _ = self.exec_subprocess(command + ['--gn_gen_only'])
if returncode != 0:
return returncode
# Determine which tests are affected and only build those tests.
affected_tests = self._get_affected_tests()
if affected_tests:
logging.info('Building %s tests affected by this change.',
len(affected_tests))
affected_tests_arg = ','.join(sorted(affected_tests))
command += ['--whitelisted_tests', affected_tests_arg]
else:
logging.info(
'Affected test calculation returned no results, will build all '
'tests instead.')
returncode, stdout, _ = self.exec_subprocess(command)
if returncode != 0:
comments = ninja_utils.ninja_failure_to_comments(stdout, max_errors=3)
for comment in comments:
self.add_review({'message': comment})
else:
returncode = self.ArchiveUnitTests()
return returncode
def _get_unit_test_out_dir(self):
"""Generate build-specific out dir name.
The out_dir needs to match the name of the build_args_product.
E.g. out/atv_arm_eng for Android eng builds.
Returns:
Path to UnittestStep out dir
"""
return os.path.join(['out', self._build_args_product])
def ArchiveUnitTests(self, dest_name='test_deps.zip'): # pylint: disable=method-hidden
"""Saving build_tests_android.zip to gcs_dir.
Args:
dest_name: desired archive name after upload.
Returns:
0 if succeed, returncode > 0 if failed
"""
# Skip archive step if no build_tests_android.zip expected
# build_tests_android.zip been generated by GetTestDepsTargets in
# chromecast/internal/build/build_chromecast.py
is_android_or_atv = any(product in self._build_args_product
for product in ['cube', 'soba', 'udon', 'atv'])
if not is_android_or_atv or self._junit:
return 0
out_dir = self._get_unit_test_out_dir()
archive_path = 'chromium/src/{}/{}/tests/build_tests_android.zip'.format(
out_dir)
# Archive only if build_tests_android.zip was generated
if not os.path.isfile(archive_path):
return 0
archive_dir = os.path.join(self.get_gcs_dir(), 'artifacts/tests')
command = ['mkdir', '-p', archive_dir]
returncode, _, _ = self.exec_subprocess(command)
if not returncode:
command = ['cp', archive_path, os.path.join(archive_dir, dest_name)]
returncode, _, _ = self.exec_subprocess(command)
return returncode
def _get_changed_files(self):
if self._changed_files is None:
if self.patch_project:
all_patches = [{
'project': self.patch_project,
'branch': self.get_property('patch_branch')}] + self.depends_on_list
else:
all_patches = self.depends_on_list
self._changed_files = git_utils.get_changed_files_in_all_patches(
self, all_patches, self.get_project_path_lookup_table())
return self._changed_files
def _must_run_all_tests(self):
"""Returns if all the unittests must be ran based on the changed files."""
if self._absolute_coverage:
return True
if not self._run_only_affected_tests:
return True
for project, patterns in FORCE_RUN_ALL_TESTS_WHITELIST.items():
for pattern in patterns:
full_pattern = os.path.join(self.get_project_path(project), pattern)
if any(re.search(full_pattern, f) for f in self._get_changed_files()):
return True
# .gni files can get imported anywhere and cause deps, configs, etc.
# to change. It would be too burdensome to try to find the affected
# targets in this case, so we need to build/run all the tests.
if any(f.endswith('.gni') for f in self._get_changed_files()):
return True
return False
def _get_affected_tests(self):
"""Return a list of the testing binaries affected by the changed files.
Uses gn to determine which test binaries are affected.
Returns:
A set of testing binaries affected by the changed files.
Raises:
GnUtilsError: If the gn binary cannot be found.
GnExecutionError: If the gn command fails.
"""
if not self._get_changed_files():
return set()
# Non-gn files can be passed to gn refs normally. If a CL modifies a
# BUILD.gn file, then instead pass a GN label that matches against
# all targets in that BUILD.gn file, otherwise GN will effectively
# ignore the file. (e.g. //foo/bar/BUILD.gn -> //foo/bar:*).
gn_refs_targets = []
for f in self._get_changed_files():
# Only care about the BUILD.gn files in chromium/src. Some projects
# outside chromium/src also contain BUILD.gn files,
# e.g. <eurekaroot>/external/gwifi/libchrome-cros, which should
# NOT be considered to affect unit tests
if f.endswith('BUILD.gn') and f.startswith('chromium/src'):
gn_refs_targets.append(gn_utils.filepath_to_gn_path(f))
else:
gn_refs_targets.append(f)
gn_binary = gn_utils.get_gn_binary(self)
is_android_or_atv = any(product in self._build_args_product
for product in ['cube', 'soba', 'udon', 'atv'])
if is_android_or_atv:
return self._get_android_affected_tests(gn_binary)
return self._get_linux_affected_tests(gn_binary, gn_refs_targets)
def _get_linux_affected_tests(self, gn_binary, targets):
# If there's just way too many targets then just use all unit tests
# otherwise we could be waiting a very long time calculating that we
# do, in fact, need to run all the unit tests
if len(targets) > MAX_TARGETS_FOR_ALL_TESTS:
command = [
gn_binary, 'refs',
'--root={}'.format(self.get_project_path('chromium/src')),
self._outdir, '--testonly=true', '--as=output', '--type=executable',
'--all', '-q', '*'
]
else:
# The non-android test templates generate normal test executables, so
# look for those.
targets_str = '\n'.join(targets + [''])
tmp_file_path = self.write_temp_file(targets_str)
command = [
gn_binary, 'refs',
'--root={}'.format(self.get_project_path('chromium/src')),
self._outdir, '--testonly=true', '--as=output', '--type=executable',
'--all', '-q', '@' + tmp_file_path
]
returncode, stdout, _ = self.exec_subprocess(command)
if returncode != 0:
raise GnExecutionError(
'Could not determine the tests affected by this change.')
return set(test.strip() for test in stdout.splitlines() if test.strip())
def _get_android_affected_tests(self, gn_binary):
# Determining affected tests for Android builds has been non-deterministic.
# (b/145934413, b/132997084, etc.) allowing breaking changes through
# presubmit. Until that is fixed, always run all test suites.
command = [
gn_binary, 'refs',
'--root={}'.format(self.get_project_path('chromium/src')), self._outdir,
'--testonly=true', '--as=label',
'--type={}'.format('group' if self._junit else 'action'), '--all', '-q',
'*'
]
returncode, stdout, _ = self.exec_subprocess(command)
if returncode != 0:
raise GnExecutionError(
'Could not determine the tests affected by this change.')
# Convert each label returned in |stdout| to a GN target name. Some of the
# labels are legitimate test targets, but many are intermediate targets
# which are internal details of GN templates. Do some basic filtering to
# cut the list down. The rest will be filtered by the cast_test_list.
return set(
gn_utils.gn_label_to_target_name(label.strip())
for label in stdout.splitlines()
if label.strip() and '__' not in label)
def run(self):
"""Builds unittests.
Returns:
True iff there were no errors.
"""
returncode = self.BuildUnitTests()
if returncode != 0:
return False
run_test_list = (
RUN_JUNIT_TEST_LIST_PATH if self._junit else RUN_TEST_LIST_PATH)
step_data_key = (
STEP_DATA_JUNIT_TEST_LIST if self._junit else STEP_DATA_TEST_LIST)
# Load the tests to be run.
test_list = self.exec_read_file(os.path.join(self._outdir,
run_test_list)).splitlines()
# Load the tests affected by this set of patches.
try:
affected_tests = self._get_affected_tests()
except gn_utils.GnUtilsError as e:
self.add_review({'message': str(e)})
return False
except RuntimeError as e:
self.add_review({'message': str(e)})
return False
if not self._must_run_all_tests() and affected_tests:
# Filter the test list for tests that are affected by the changed files
# and save this in step data.
test_list = [
test_cmd for test_cmd in test_list
if test_cmd.split()[0] in affected_tests
]
self.add_step_data(step_data_key, test_list)
return True
class RunAffectedUnitTestsStep(cast_shell_step.NonOtaBuildBaseStep):
"""Build step class for running x86 unit tests, if any."""
def __init__(self,
report_url,
recipe_steps,
sanitizer=None,
build_args_product=None,
build_args_official=None,
junit=False,
enable_network_service=False,
**kwargs):
"""Instantiate the class.
Args:
report_url: The URL the code coverage report can be accessed at.
recipe_steps: Reference to the list of steps for this recipe.
sanitizer: Clang saniziter 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).
enable_network_service: If true, include --enable-features=NetworkService
**kwargs: Any additional args to pass to BaseStep.
"""
if junit:
step_name = 'run affected junit tests'
else:
step_name = 'run affected gtest unit tests'
cast_shell_step.NonOtaBuildBaseStep.__init__(
self,
step_name,
build_args_product=build_args_product,
build_args_official=build_args_official,
**kwargs)
assert isinstance(report_url, six.string_types)
assert isinstance(recipe_steps, list)
assert build_args_product
self._report_url = report_url
self._recipe_steps = recipe_steps
self._sanitizer = sanitizer
self._junit = junit
self._enable_network_service = enable_network_service
self._step_kwargs = kwargs
# Index to insert new steps at. This is to make sure that different steps that
# are included after the RunAffectedUnitTestsStep actually run after it,
# rather than the new steps being appended after them.
self._insert_index = None
def get_report_dir(self):
"""Returns a unit test result directory."""
report_dir = os.path.join(self.get_gcs_dir(), UNIT_TEST_REPORT_PATH)
if not os.path.isdir(report_dir):
os.mkdir(report_dir)
return report_dir
def get_gtest_xml_log_uri(self, test_name):
"""Returns a unit test xml log uri for the given gtest."""
return '{}/test_detail_{}.xml'.format(self.get_report_dir(), test_name)
def get_gtest_step(self, test_name, test_command):
"""Returns a step that runs the unit test executable."""
if self._sanitizer:
return sanitizer_unittest_step.SanitizerUnitTestShellStep(
name=test_name,
command=test_command,
directory=self._outdir,
sanitizer=self._sanitizer,
**self._step_kwargs)
return shell_step.ShellStep(
name=test_name,
command=test_command,
directory=self._outdir,
**self._step_kwargs)
def get_junit_step(self, test_name):
"""Returns a step that runs a junit test."""
return self.get_android_runner_step(test_name, 'junit')
def get_android_runner_step(self, test_name, test_type):
"""Returns a step that runs an android test."""
test_runner = self._get_test_runner()
pythonpath = self._get_python_path()
# Single device, should be single test.
wrapper_command = [
'python3',
test_runner,
'--debug', # Dumps test output to stdout.
'--type',
test_type,
'--test_result_dir',
self.get_gcs_dir(),
'--unit_test_binary_dir',
self._outdir,
'--unittest_name',
test_name
]
return shell_step.ShellStep(
name=test_name,
command=wrapper_command,
directory='',
env={'PYTHONPATH': pythonpath},
**self._step_kwargs)
def get_json_step(self, test_name):
"""Returns a step that generates test_result.json from existing xml."""
report_dir = self.get_report_dir()
test_project_dir = self.get_project_path(ANDROID_TEST_RUNNER_PROJECT)
xml_to_json = os.path.join(test_project_dir, TRANSFORM_XML_TO_JSON_PATH)
wrapper_command = [
'python3', xml_to_json, '--xml_dir_path', report_dir, '--branch_name',
self.manifest_branch, '--build_number', self.build_number,
'--test_name', test_name
]
wrapper_command += [
'--build_args_product',
self._build_args_product,
]
return shell_step.ShellStep(
name='summary json for {}'.format(test_name),
command=wrapper_command,
directory='',
env={'PYTHONPATH': test_project_dir},
**self._step_kwargs)
def _add_step_to_recipe_steps(self, step):
if self._insert_index is None:
self._insert_index = self._recipe_steps.index(self) + 1
self._recipe_steps.insert(self._insert_index, step)
self._insert_index += 1
def add_gtest_tests(self):
test_list = self.get_step_data(STEP_DATA_TEST_LIST)
for test_exec_text in test_list:
test_command = test_exec_text.split(' ')
test_name = test_command[0]
test_command[0] = './' + test_name
if (self._enable_network_service and
branch_utils.is_branch_equal_to_or_later_than(self.manifest_branch,
'1.41')):
test_command.append(
'--enable-features=NetworkService,NetworkServiceInProcess')
# TODO(b/130182642): Re-enable these tests once Chromium supports
# network service in unit tests.
disabled_tests = (':CastDevToolsManagerDelegateTest.*'
':CastMediaBlockerTest.*'
':CastSessionIdMapTest.*')
for i, part in enumerate(test_command):
if part.startswith('--gtest_filter=-'):
test_command[i] = part + disabled_tests
break
if i == len(test_command) - 1:
test_command.append('--gtest_filter=-' + disabled_tests)
test_command.append('--gtest_output=xml:{}'.format(
self.get_gtest_xml_log_uri(test_name)))
self._add_step_to_recipe_steps(
self.get_gtest_step(test_name, test_command))
self._add_step_to_recipe_steps(self.get_json_step('unit_test'))
def add_junit_tests(self):
test_list = self.get_step_data(STEP_DATA_JUNIT_TEST_LIST)
for test_exec_text in test_list:
test_name = test_exec_text.split(' ')[0]
self._add_step_to_recipe_steps(self.get_junit_step(test_name))
def _get_test_runner(self):
test_project_dir = self.get_project_path(ANDROID_TEST_RUNNER_PROJECT)
return os.path.join(test_project_dir, ANDROID_TEST_RUNNER_PATH)
def _get_python_path(self):
test_project_dir = self.get_project_path(ANDROID_TEST_RUNNER_PROJECT)
return ':'.join(
[test_project_dir,
self.get_project_path('continuous-tests')])
def run(self):
"""Adds a separate step for each Chromium test case to be executed.
Returns:
True, it shouldn't be possible for this step to fail.
"""
original_step_count = len(self._recipe_steps)
if self._junit:
self.add_junit_tests()
else:
self.add_gtest_tests()
new_steps_count = len(self._recipe_steps) - original_step_count
if new_steps_count:
logging.info('Running %s tests affecting this change.', new_steps_count)
else:
logging.info('No affected tests were detected for this change. None '
'will be run.')
return True