| # Copyright 2018 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. |
| |
| """Test runner for running tests using xcodebuild.""" |
| |
| import collections |
| import logging |
| import os |
| import subprocess |
| import time |
| |
| import file_util |
| import iossim_util |
| import test_apps |
| from test_result_util import ResultCollection, TestResult, TestStatus |
| import test_runner |
| import xcode_log_parser |
| |
| LOGGER = logging.getLogger(__name__) |
| MAXIMUM_TESTS_PER_SHARD_FOR_RERUN = 20 |
| XTDEVICE_FOLDER = os.path.expanduser('~/Library/Developer/XCTestDevices') |
| |
| |
| def _tests_decided_at_runtime(app_name): |
| """Return if tests in app are selected at runtime by app_name. |
| |
| This works for suites defined in chromium infra. |
| """ |
| suite_name_fragments = ['ios_chrome_multitasking_eg', '_flaky_eg'] |
| return any(fragment in app_name for fragment in suite_name_fragments) |
| |
| |
| def erase_all_simulators(path=None): |
| """Erases all simulator devices. |
| |
| Args: |
| path: (str) A path with simulators |
| """ |
| command = ['xcrun', 'simctl'] |
| if path: |
| command += ['--set', path] |
| LOGGER.info('Erasing all simulators from folder %s.' % path) |
| else: |
| LOGGER.info('Erasing all simulators.') |
| |
| try: |
| subprocess.check_call(command + ['erase', 'all']) |
| except subprocess.CalledProcessError as e: |
| # Logging error instead of throwing so we don't cause failures in case |
| # this was indeed failing to clean up. |
| message = 'Failed to erase all simulators. Error: %s' % e.output |
| LOGGER.error(message) |
| |
| |
| def shutdown_all_simulators(path=None): |
| """Shutdown all simulator devices. |
| |
| Fix for DVTCoreSimulatorAdditionsErrorDomain error. |
| |
| Args: |
| path: (str) A path with simulators |
| """ |
| command = ['xcrun', 'simctl'] |
| if path: |
| command += ['--set', path] |
| LOGGER.info('Shutdown all simulators from folder %s.' % path) |
| else: |
| LOGGER.info('Shutdown all simulators.') |
| |
| try: |
| subprocess.check_call(command + ['shutdown', 'all']) |
| except subprocess.CalledProcessError as e: |
| # Logging error instead of throwing so we don't cause failures in case |
| # this was indeed failing to clean up. |
| message = 'Failed to shutdown all simulators. Error: %s' % e.output |
| LOGGER.error(message) |
| |
| |
| def terminate_process(proc): |
| """Terminates the process. |
| |
| If an error occurs ignore it, just print out a message. |
| |
| Args: |
| proc: A subprocess. |
| """ |
| try: |
| proc.terminate() |
| except OSError as ex: |
| LOGGER.error('Error while killing a process: %s' % ex) |
| |
| |
| class LaunchCommand(object): |
| """Stores xcodebuild test launching command.""" |
| |
| def __init__(self, |
| egtests_app, |
| udid, |
| shards, |
| retries, |
| readline_timeout, |
| out_dir=os.path.basename(os.getcwd()), |
| use_clang_coverage=False, |
| env=None): |
| """Initialize launch command. |
| |
| Args: |
| egtests_app: (EgtestsApp) An egtests_app to run. |
| udid: (str) UDID of a device/simulator. |
| shards: (int) A number of shards. |
| readline_timeout: (int) Timeout to kill a test process when it doesn't |
| have output (in seconds). |
| retries: (int) A number of retries. |
| out_dir: (str) A folder in which xcodebuild will generate test output. |
| By default it is a current directory. |
| env: (dict) Environment variables. |
| |
| Raises: |
| AppNotFoundError: At incorrect egtests_app parameter type. |
| """ |
| if not isinstance(egtests_app, test_apps.EgtestsApp): |
| raise test_runner.AppNotFoundError( |
| 'Parameter `egtests_app` is not EgtestsApp: %s' % egtests_app) |
| self.egtests_app = egtests_app |
| self.udid = udid |
| self.shards = shards |
| self.readline_timeout = readline_timeout |
| self.retries = retries |
| self.out_dir = out_dir |
| self.use_clang_coverage = use_clang_coverage |
| self.env = env |
| self._log_parser = xcode_log_parser.get_parser() |
| |
| def launch_attempt(self, cmd): |
| """Launch a process and do logging simultaneously. |
| |
| Args: |
| cmd: (list[str]) A command to run. |
| |
| Returns: |
| output - command output as list of strings. |
| """ |
| proc = subprocess.Popen( |
| cmd, |
| env=self.env, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| ) |
| return test_runner.print_process_output(proc, timeout=self.readline_timeout) |
| |
| def launch(self): |
| """Launches tests using xcodebuild.""" |
| overall_launch_command_result = ResultCollection() |
| shards = self.shards |
| running_tests = set(self.egtests_app.get_all_tests()) |
| # total number of attempts is self.retries+1 |
| for attempt in range(self.retries + 1): |
| # Erase all simulators per each attempt |
| if iossim_util.is_device_with_udid_simulator(self.udid): |
| # kill all running simulators to prevent possible memory leaks |
| test_runner.SimulatorTestRunner.kill_simulators() |
| shutdown_all_simulators() |
| shutdown_all_simulators(XTDEVICE_FOLDER) |
| erase_all_simulators() |
| erase_all_simulators(XTDEVICE_FOLDER) |
| outdir_attempt = os.path.join(self.out_dir, 'attempt_%d' % attempt) |
| cmd_list = self.egtests_app.command(outdir_attempt, 'id=%s' % self.udid, |
| shards) |
| # TODO(crbug.com/914878): add heartbeat logging to xcodebuild_runner. |
| LOGGER.info('Start test attempt #%d for command [%s]' % ( |
| attempt, ' '.join(cmd_list))) |
| output = self.launch_attempt(cmd_list) |
| |
| if hasattr(self, 'use_clang_coverage') and self.use_clang_coverage: |
| # out_dir of LaunchCommand object is the TestRunner out_dir joined with |
| # UDID. Use os.path.dirname to retrieve the TestRunner out_dir. |
| file_util.move_raw_coverage_data(self.udid, |
| os.path.dirname(self.out_dir)) |
| |
| result = self._log_parser.collect_test_results(outdir_attempt, output) |
| |
| tests_selected_at_runtime = _tests_decided_at_runtime( |
| self.egtests_app.test_app_path) |
| # For most suites, only keep crash status from last attempt since retries |
| # will cover any missing tests. For these decided at runtime, retain |
| # crashes from all attempts and a dummy "crashed" result will be reported |
| # to indicate some tests might never ran. |
| # TODO(crbug.com/1235871): Switch back to excluded tests and set |
| # |overall_crash| to always True. |
| overall_launch_command_result.add_result_collection( |
| result, overwrite_crash=not tests_selected_at_runtime) |
| result.report_to_result_sink() |
| |
| tests_to_include = set() |
| # |running_tests| are compiled tests in target intersecting with swarming |
| # sharding. For some suites, they are more than what's needed to run. |
| if not tests_selected_at_runtime: |
| tests_to_include = tests_to_include | ( |
| running_tests - overall_launch_command_result.expected_tests()) |
| # Add failed tests from last rounds for runtime decided suites and device |
| # suites. |
| tests_to_include = ( |
| tests_to_include |
| | overall_launch_command_result.never_expected_tests()) |
| self.egtests_app.included_tests = list(tests_to_include) |
| |
| # Nothing to run in retry. |
| if not self.egtests_app.included_tests: |
| break |
| |
| # If tests are not completed(interrupted or did not start) and there are |
| # >= 20 remaining tests, run them with the same number of shards. |
| # otherwise re-run with shards=1. |
| if (not result.crashed |
| # If need to re-run less than 20 tests, 1 shard should be enough. |
| or (len(running_tests) - |
| len(overall_launch_command_result.expected_tests()) <= |
| MAXIMUM_TESTS_PER_SHARD_FOR_RERUN)): |
| shards = 1 |
| |
| return overall_launch_command_result |
| |
| |
| class SimulatorParallelTestRunner(test_runner.SimulatorTestRunner): |
| """Class for running simulator tests using xCode.""" |
| |
| def __init__(self, app_path, host_app_path, iossim_path, version, platform, |
| out_dir, **kwargs): |
| """Initializes a new instance of SimulatorParallelTestRunner class. |
| |
| Args: |
| app_path: (str) A path to egtests_app. |
| host_app_path: (str) A path to the host app for EG2. |
| iossim_path: Path to the compiled iossim binary to use. |
| Not used, but is required by the base class. |
| version: (str) iOS version to run simulator on. |
| platform: (str) Name of device. |
| out_dir: (str) A directory to emit test data into. |
| (Following are potential args in **kwargs) |
| release: (bool) Whether this test runner is running for a release build. |
| repeat_count: (int) Number of times to run each test (passed to test app). |
| retries: (int) A number to retry test run, will re-run only failed tests. |
| shards: (int) A number of shards. Default is 1. |
| test_cases: (list) List of tests to be included in the test run. |
| None or [] to include all tests. |
| test_args: List of strings to pass as arguments to the test when |
| launching. |
| use_clang_coverage: Whether code coverage is enabled in this run. |
| env_vars: List of environment variables to pass to the test itself. |
| |
| Raises: |
| AppNotFoundError: If the given app does not exist. |
| PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests. |
| XcodeVersionNotFoundError: If the given Xcode version does not exist. |
| XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist. |
| """ |
| kwargs['retries'] = kwargs.get('retries') or 1 |
| super(SimulatorParallelTestRunner, |
| self).__init__(app_path, iossim_path, platform, version, out_dir, |
| **kwargs) |
| self.set_up() |
| self.host_app_path = None |
| if host_app_path != 'NO_PATH': |
| self.host_app_path = os.path.abspath(host_app_path) |
| self.logs = collections.OrderedDict() |
| self.release = kwargs.get('release') or False |
| self.test_results['path_delimiter'] = '/' |
| # Do not enable parallel testing when code coverage is enabled, because raw |
| # coverage data won't be produced with parallel testing. |
| if hasattr(self, 'use_clang_coverage') and self.use_clang_coverage: |
| self.shards = 1 |
| |
| def get_launch_env(self): |
| """Returns a dict of environment variables to use to launch the test app. |
| |
| Returns: |
| A dict of environment variables. |
| """ |
| env = super(test_runner.SimulatorTestRunner, self).get_launch_env() |
| env['NSUnbufferedIO'] = 'YES' |
| return env |
| |
| def get_launch_test_app(self): |
| """Returns the proper test_app for the run. |
| |
| Returns: |
| An implementation of EgtestsApp for the runner. |
| """ |
| return test_apps.EgtestsApp( |
| self.app_path, |
| included_tests=self.test_cases, |
| env_vars=self.env_vars, |
| test_args=self.test_args, |
| release=self.release, |
| repeat_count=self.repeat_count, |
| host_app_path=self.host_app_path) |
| |
| def launch(self): |
| """Launches tests using xcodebuild.""" |
| test_app = self.get_launch_test_app() |
| launch_command = LaunchCommand( |
| test_app, |
| udid=self.udid, |
| shards=self.shards, |
| readline_timeout=self.readline_timeout, |
| retries=self.retries, |
| out_dir=os.path.join(self.out_dir, self.udid), |
| use_clang_coverage=(hasattr(self, 'use_clang_coverage') and |
| self.use_clang_coverage), |
| env=self.get_launch_env()) |
| |
| overall_result = launch_command.launch() |
| |
| # Deletes simulator used in the tests after tests end. |
| if iossim_util.is_device_with_udid_simulator(self.udid): |
| iossim_util.delete_simulator_by_udid(self.udid) |
| |
| # Adds disabled tests to result. |
| overall_result.add_and_report_test_names_status( |
| launch_command.egtests_app.disabled_tests, |
| TestStatus.SKIP, |
| expected_status=TestStatus.SKIP, |
| test_log='Test disabled.') |
| |
| # Adds unexpectedly skipped tests to result if applicable. |
| tests_selected_at_runtime = _tests_decided_at_runtime(self.app_path) |
| unexpectedly_skipped = [] |
| # TODO(crbug.com/1048758): For the multitasking or any flaky test suites, |
| # |all_tests_to_run| contains more tests than what actually runs. |
| if not tests_selected_at_runtime: |
| # |all_tests_to_run| takes into consideration that only a subset of tests |
| # may have run due to the test sharding logic in run.py. |
| all_tests_to_run = set(launch_command.egtests_app.get_all_tests()) |
| unexpectedly_skipped = list(all_tests_to_run - |
| overall_result.all_test_names()) |
| overall_result.add_and_report_test_names_status( |
| unexpectedly_skipped, |
| TestStatus.SKIP, |
| test_log=('The test is compiled in test target but was unexpectedly ' |
| 'not run or not finished.')) |
| |
| # Add a final crash status to result collection. It will be reported as |
| # part of step log in LUCI build. |
| if unexpectedly_skipped or overall_result.crashed: |
| overall_result.set_crashed_with_prefix( |
| crash_message_prefix_line=('Test application crash happened and may ' |
| 'result in missing tests:')) |
| |
| self.test_results = overall_result.standard_json_output(path_delimiter='/') |
| self.logs.update(overall_result.test_runner_logs()) |
| |
| # Return False when: |
| # - There are unexpected tests (all results of the tests are unexpected), or |
| # - The overall status is crashed and tests are selected at runtime. (i.e. |
| # runner is unable to know if all scheduled tests appear in result.) |
| return (not overall_result.never_expected_tests() and |
| not (tests_selected_at_runtime and overall_result.crashed)) |
| |
| |
| class DeviceXcodeTestRunner(SimulatorParallelTestRunner, |
| test_runner.DeviceTestRunner): |
| """Class for running tests on real device using xCode.""" |
| |
| def __init__(self, app_path, host_app_path, out_dir, **kwargs): |
| """Initializes a new instance of DeviceXcodeTestRunner class. |
| |
| Args: |
| app_path: (str) A path to egtests_app. |
| host_app_path: (str) A path to the host app for EG2. |
| out_dir: (str) A directory to emit test data into. |
| (Following are potential args in **kwargs) |
| repeat_count: (int) Number of times to run each test (passed to test app). |
| retries: (int) A number to retry test run, will re-run only failed tests. |
| test_cases: (list) List of tests to be included in the test run. |
| None or [] to include all tests. |
| test_args: List of strings to pass as arguments to the test when |
| launching. |
| env_vars: List of environment variables to pass to the test itself. |
| |
| Raises: |
| AppNotFoundError: If the given app does not exist. |
| DeviceDetectionError: If no device found. |
| PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests. |
| XcodeVersionNotFoundError: If the given Xcode version does not exist. |
| XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist. |
| """ |
| test_runner.DeviceTestRunner.__init__(self, app_path, out_dir, **kwargs) |
| self.shards = 1 # For tests on real devices shards=1 |
| self.version = None |
| self.platform = None |
| self.host_app_path = None |
| if host_app_path != 'NO_PATH': |
| self.host_app_path = os.path.abspath(host_app_path) |
| self.homedir = '' |
| self.release = kwargs.get('release') or False |
| self.set_up() |
| self.start_time = time.strftime('%Y-%m-%d-%H%M%S', time.localtime()) |
| self.test_results['path_delimiter'] = '/' |
| |
| def set_up(self): |
| """Performs setup actions which must occur prior to every test launch.""" |
| self.uninstall_apps() |
| self.wipe_derived_data() |
| |
| def tear_down(self): |
| """Performs cleanup actions which must occur after every test launch.""" |
| test_runner.DeviceTestRunner.tear_down(self) |
| |
| def launch(self): |
| try: |
| return super(DeviceXcodeTestRunner, self).launch() |
| finally: |
| self.tear_down() |