1# Copyright 2018 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5"""Test runner for running tests using xcodebuild.""" 6 7import collections 8import distutils.version 9import logging 10from multiprocessing import pool 11import os 12import subprocess 13import time 14 15import file_util 16import iossim_util 17import standard_json_util as sju 18import test_apps 19import test_runner 20import xcode_log_parser 21 22LOGGER = logging.getLogger(__name__) 23MAXIMUM_TESTS_PER_SHARD_FOR_RERUN = 20 24XTDEVICE_FOLDER = os.path.expanduser('~/Library/Developer/XCTestDevices') 25 26 27class LaunchCommandCreationError(test_runner.TestRunnerError): 28 """One of launch command parameters was not set properly.""" 29 30 def __init__(self, message): 31 super(LaunchCommandCreationError, self).__init__(message) 32 33 34class LaunchCommandPoolCreationError(test_runner.TestRunnerError): 35 """Failed to create a pool of launch commands.""" 36 37 def __init__(self, message): 38 super(LaunchCommandPoolCreationError, self).__init__(message) 39 40 41def erase_all_simulators(path=None): 42 """Erases all simulator devices. 43 44 Args: 45 path: (str) A path with simulators 46 """ 47 command = ['xcrun', 'simctl'] 48 if path: 49 command += ['--set', path] 50 LOGGER.info('Erasing all simulators from folder %s.' % path) 51 else: 52 LOGGER.info('Erasing all simulators.') 53 54 try: 55 subprocess.check_call(command + ['erase', 'all']) 56 except subprocess.CalledProcessError as e: 57 # Logging error instead of throwing so we don't cause failures in case 58 # this was indeed failing to clean up. 59 message = 'Failed to erase all simulators. Error: %s' % e.output 60 LOGGER.error(message) 61 62 63def shutdown_all_simulators(path=None): 64 """Shutdown all simulator devices. 65 66 Fix for DVTCoreSimulatorAdditionsErrorDomain error. 67 68 Args: 69 path: (str) A path with simulators 70 """ 71 command = ['xcrun', 'simctl'] 72 if path: 73 command += ['--set', path] 74 LOGGER.info('Shutdown all simulators from folder %s.' % path) 75 else: 76 LOGGER.info('Shutdown all simulators.') 77 78 try: 79 subprocess.check_call(command + ['shutdown', 'all']) 80 except subprocess.CalledProcessError as e: 81 # Logging error instead of throwing so we don't cause failures in case 82 # this was indeed failing to clean up. 83 message = 'Failed to shutdown all simulators. Error: %s' % e.output 84 LOGGER.error(message) 85 86 87def terminate_process(proc): 88 """Terminates the process. 89 90 If an error occurs ignore it, just print out a message. 91 92 Args: 93 proc: A subprocess. 94 """ 95 try: 96 proc.terminate() 97 except OSError as ex: 98 LOGGER.error('Error while killing a process: %s' % ex) 99 100 101class LaunchCommand(object): 102 """Stores xcodebuild test launching command.""" 103 104 def __init__(self, 105 egtests_app, 106 udid, 107 shards, 108 retries, 109 out_dir=os.path.basename(os.getcwd()), 110 use_clang_coverage=False, 111 env=None): 112 """Initialize launch command. 113 114 Args: 115 egtests_app: (EgtestsApp) An egtests_app to run. 116 udid: (str) UDID of a device/simulator. 117 shards: (int) A number of shards. 118 retries: (int) A number of retries. 119 out_dir: (str) A folder in which xcodebuild will generate test output. 120 By default it is a current directory. 121 env: (dict) Environment variables. 122 123 Raises: 124 LaunchCommandCreationError: if one of parameters was not set properly. 125 """ 126 if not isinstance(egtests_app, test_apps.EgtestsApp): 127 raise test_runner.AppNotFoundError( 128 'Parameter `egtests_app` is not EgtestsApp: %s' % egtests_app) 129 self.egtests_app = egtests_app 130 self.udid = udid 131 self.shards = shards 132 self.retries = retries 133 self.out_dir = out_dir 134 self.logs = collections.OrderedDict() 135 self.test_results = collections.OrderedDict() 136 self.use_clang_coverage = use_clang_coverage 137 self.env = env 138 self._log_parser = xcode_log_parser.get_parser() 139 140 def summary_log(self): 141 """Calculates test summary - how many passed, failed and error tests. 142 143 Returns: 144 Dictionary with number of passed and failed tests. 145 Failed tests will be calculated from the last test attempt. 146 Passed tests calculated for each test attempt. 147 """ 148 test_statuses = ['passed', 'failed'] 149 for status in test_statuses: 150 self.logs[status] = 0 151 152 for index, test_attempt_results in enumerate(self.test_results['attempts']): 153 for test_status in test_statuses: 154 if test_status not in test_attempt_results: 155 continue 156 if (test_status == 'passed' 157 # Number of failed tests is taken only from last run. 158 or (test_status == 'failed' 159 and index == len(self.test_results['attempts']) - 1)): 160 self.logs[test_status] += len(test_attempt_results[test_status]) 161 162 def launch_attempt(self, cmd): 163 """Launch a process and do logging simultaneously. 164 165 Args: 166 cmd: (list[str]) A command to run. 167 168 Returns: 169 output - command output as list of strings. 170 """ 171 proc = subprocess.Popen( 172 cmd, 173 env=self.env, 174 stdout=subprocess.PIPE, 175 stderr=subprocess.STDOUT, 176 ) 177 return test_runner.print_process_output(proc) 178 179 def launch(self): 180 """Launches tests using xcodebuild.""" 181 self.test_results['attempts'] = [] 182 cancelled_statuses = {'TESTS_DID_NOT_START', 'BUILD_INTERRUPTED'} 183 shards = self.shards 184 running_tests = set(self.egtests_app.get_all_tests()) 185 # total number of attempts is self.retries+1 186 for attempt in range(self.retries + 1): 187 # Erase all simulators per each attempt 188 if iossim_util.is_device_with_udid_simulator(self.udid): 189 # kill all running simulators to prevent possible memory leaks 190 test_runner.SimulatorTestRunner.kill_simulators() 191 shutdown_all_simulators() 192 shutdown_all_simulators(XTDEVICE_FOLDER) 193 erase_all_simulators() 194 erase_all_simulators(XTDEVICE_FOLDER) 195 outdir_attempt = os.path.join(self.out_dir, 'attempt_%d' % attempt) 196 cmd_list = self.egtests_app.command(outdir_attempt, 'id=%s' % self.udid, 197 shards) 198 # TODO(crbug.com/914878): add heartbeat logging to xcodebuild_runner. 199 LOGGER.info('Start test attempt #%d for command [%s]' % ( 200 attempt, ' '.join(cmd_list))) 201 output = self.launch_attempt(cmd_list) 202 203 if hasattr(self, 'use_clang_coverage') and self.use_clang_coverage: 204 # out_dir of LaunchCommand object is the TestRunner out_dir joined with 205 # UDID. Use os.path.dirname to retrieve the TestRunner out_dir. 206 file_util.move_raw_coverage_data(self.udid, 207 os.path.dirname(self.out_dir)) 208 self.test_results['attempts'].append( 209 self._log_parser.collect_test_results(outdir_attempt, output)) 210 211 # Do not exit here when no failed test from parsed log and parallel 212 # testing is enabled (shards > 1), because when one of the shards fails 213 # before tests start , the tests not run don't appear in log at all. 214 if (self.retries == attempt or 215 (shards == 1 and not self.test_results['attempts'][-1]['failed'])): 216 break 217 218 # Exclude passed tests in next test attempt. 219 self.egtests_app.excluded_tests += self.test_results['attempts'][-1][ 220 'passed'] 221 # crbug.com/987664 - for the case when 222 # all tests passed but build was interrupted, 223 # excluded(passed) tests are equal to tests to run. 224 if set(self.egtests_app.excluded_tests) == running_tests: 225 for status in cancelled_statuses: 226 failure = self.test_results['attempts'][-1]['failed'].pop( 227 status, None) 228 if failure: 229 LOGGER.info('Failure for passed tests %s: %s' % (status, failure)) 230 break 231 232 # If tests are not completed(interrupted or did not start) 233 # re-run them with the same number of shards, 234 # otherwise re-run with shards=1 and exclude passed tests. 235 cancelled_attempt = cancelled_statuses.intersection( 236 self.test_results['attempts'][-1]['failed'].keys()) 237 238 # Item in cancelled_statuses is used to config for next attempt. The usage 239 # should be confined in this method. Real tests affected by these statuses 240 # will be marked timeout in results. 241 for status in cancelled_statuses: 242 self.test_results['attempts'][-1]['failed'].pop(status, None) 243 244 if (not cancelled_attempt 245 # If need to re-run less than 20 tests, 1 shard should be enough. 246 or (len(running_tests) - len(self.egtests_app.excluded_tests) 247 <= MAXIMUM_TESTS_PER_SHARD_FOR_RERUN)): 248 shards = 1 249 250 self.summary_log() 251 252 return { 253 'test_results': self.test_results, 254 'logs': self.logs 255 } 256 257 258class SimulatorParallelTestRunner(test_runner.SimulatorTestRunner): 259 """Class for running simulator tests using xCode.""" 260 261 def __init__(self, 262 app_path, 263 host_app_path, 264 iossim_path, 265 version, 266 platform, 267 out_dir, 268 release=False, 269 retries=1, 270 shards=1, 271 test_cases=None, 272 test_args=None, 273 use_clang_coverage=False, 274 env_vars=None): 275 """Initializes a new instance of SimulatorParallelTestRunner class. 276 277 Args: 278 app_path: (str) A path to egtests_app. 279 host_app_path: (str) A path to the host app for EG2. 280 iossim_path: Path to the compiled iossim binary to use. 281 Not used, but is required by the base class. 282 version: (str) iOS version to run simulator on. 283 platform: (str) Name of device. 284 out_dir: (str) A directory to emit test data into. 285 release: (bool) Whether this test runner is running for a release build. 286 retries: (int) A number to retry test run, will re-run only failed tests. 287 shards: (int) A number of shards. Default is 1. 288 test_cases: (list) List of tests to be included in the test run. 289 None or [] to include all tests. 290 test_args: List of strings to pass as arguments to the test when 291 launching. 292 use_clang_coverage: Whether code coverage is enabled in this run. 293 env_vars: List of environment variables to pass to the test itself. 294 295 Raises: 296 AppNotFoundError: If the given app does not exist. 297 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests. 298 XcodeVersionNotFoundError: If the given Xcode version does not exist. 299 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist. 300 """ 301 super(SimulatorParallelTestRunner, self).__init__( 302 app_path, 303 iossim_path, 304 platform, 305 version, 306 out_dir, 307 env_vars=env_vars, 308 retries=retries or 1, 309 shards=shards or 1, 310 test_args=test_args, 311 test_cases=test_cases, 312 use_clang_coverage=use_clang_coverage, 313 xctest=False) 314 self.set_up() 315 self.host_app_path = None 316 if host_app_path != 'NO_PATH': 317 self.host_app_path = os.path.abspath(host_app_path) 318 self._init_sharding_data() 319 self.logs = collections.OrderedDict() 320 self.release = release 321 self.test_results['path_delimiter'] = '/' 322 # Do not enable parallel testing when code coverage is enabled, because raw 323 # coverage data won't be produced with parallel testing. 324 if hasattr(self, 'use_clang_coverage') and self.use_clang_coverage: 325 self.shards = 1 326 327 def _init_sharding_data(self): 328 """Initialize sharding data. 329 330 For common case info about sharding tests will be a list of dictionaries: 331 [ 332 { 333 'app':paths to egtests_app, 334 'udid': 'UDID of Simulator' 335 'shards': N 336 } 337 ] 338 """ 339 self.sharding_data = [{ 340 'app': self.app_path, 341 'host': self.host_app_path, 342 'udid': self.udid, 343 'shards': self.shards, 344 'test_cases': self.test_cases 345 }] 346 347 def get_launch_env(self): 348 """Returns a dict of environment variables to use to launch the test app. 349 350 Returns: 351 A dict of environment variables. 352 """ 353 env = super(test_runner.SimulatorTestRunner, self).get_launch_env() 354 env['NSUnbufferedIO'] = 'YES' 355 return env 356 357 def launch(self): 358 """Launches tests using xcodebuild.""" 359 launch_commands = [] 360 for params in self.sharding_data: 361 test_app = test_apps.EgtestsApp( 362 params['app'], 363 included_tests=params['test_cases'], 364 env_vars=self.env_vars, 365 test_args=self.test_args, 366 release=self.release, 367 host_app_path=params['host']) 368 launch_commands.append( 369 LaunchCommand( 370 test_app, 371 udid=params['udid'], 372 shards=params['shards'], 373 retries=self.retries, 374 out_dir=os.path.join(self.out_dir, params['udid']), 375 use_clang_coverage=(hasattr(self, 'use_clang_coverage') and 376 self.use_clang_coverage), 377 env=self.get_launch_env())) 378 379 thread_pool = pool.ThreadPool(len(launch_commands)) 380 attempts_results = [] 381 for result in thread_pool.imap_unordered(LaunchCommand.launch, 382 launch_commands): 383 attempts_results.append(result['test_results']['attempts']) 384 385 # Deletes simulator used in the tests after tests end. 386 if iossim_util.is_device_with_udid_simulator(self.udid): 387 iossim_util.delete_simulator_by_udid(self.udid) 388 389 # Gets passed tests 390 self.logs['passed tests'] = [] 391 for shard_attempts in attempts_results: 392 for attempt in shard_attempts: 393 self.logs['passed tests'].extend(attempt['passed']) 394 395 # If the last attempt does not have failures, mark failed as empty 396 self.logs['failed tests'] = [] 397 for shard_attempts in attempts_results: 398 if shard_attempts[-1]['failed']: 399 self.logs['failed tests'].extend(shard_attempts[-1]['failed'].keys()) 400 401 # Gets disabled tests from test app object if any. 402 self.logs['disabled tests'] = [] 403 for launch_command in launch_commands: 404 self.logs['disabled tests'].extend( 405 launch_command.egtests_app.disabled_tests) 406 407 # Gets all failures/flakes and lists them in bot summary 408 all_failures = set() 409 for shard_attempts in attempts_results: 410 for attempt, attempt_results in enumerate(shard_attempts): 411 for failure in attempt_results['failed']: 412 if failure not in self.logs: 413 self.logs[failure] = [] 414 self.logs[failure].append('%s: attempt # %d' % (failure, attempt)) 415 self.logs[failure].extend(attempt_results['failed'][failure]) 416 all_failures.add(failure) 417 418 # Gets only flaky(not failed) tests. 419 self.logs['flaked tests'] = list( 420 all_failures - set(self.logs['failed tests'])) 421 422 # Gets not-started/interrupted tests. 423 # all_tests_to_run takes into consideration that only a subset of tests may 424 # have run due to the test sharding logic in run.py. 425 all_tests_to_run = set([ 426 test_name for launch_command in launch_commands 427 for test_name in launch_command.egtests_app.get_all_tests() 428 ]) 429 430 aborted_tests = [] 431 # TODO(crbug.com/1048758): For device targets, the list of test names parsed 432 # from otool output is incorrect. For multitasking or any flaky test suite, 433 # the list contains more tests than what actually runs. 434 if (self.__class__.__name__ != 'DeviceXcodeTestRunner' and 435 'ios_chrome_multitasking_eg' not in self.app_path and 436 '_flaky_eg' not in self.app_path): 437 aborted_tests = list(all_tests_to_run - set(self.logs['failed tests']) - 438 set(self.logs['passed tests'])) 439 aborted_tests.sort() 440 self.logs['aborted tests'] = aborted_tests 441 442 self.test_results['interrupted'] = bool(aborted_tests) 443 self.test_results['num_failures_by_type'] = { 444 'FAIL': len(self.logs['failed tests'] + self.logs['aborted tests']), 445 'PASS': len(self.logs['passed tests']), 446 } 447 448 output = sju.StdJson() 449 for shard_attempts in attempts_results: 450 for attempt, attempt_results in enumerate(shard_attempts): 451 452 for test in attempt_results['failed'].keys(): 453 output.mark_failed( 454 test, test_log='\n'.join(self.logs.get(test, [])).encode('utf8')) 455 456 # 'aborted tests' in logs is an array of strings, each string defined 457 # as "{TestCase}/{testMethod}" 458 for test in self.logs['aborted tests']: 459 output.mark_timeout(test) 460 461 for test in attempt_results['passed']: 462 output.mark_passed(test) 463 464 output.mark_all_skipped(self.logs['disabled tests']) 465 output.finalize() 466 467 self.test_results['tests'] = output.tests 468 469 # Test is failed if there are failures for the last run. 470 # or if there are aborted tests. 471 return not self.logs['failed tests'] and not self.logs['aborted tests'] 472 473 474class DeviceXcodeTestRunner(SimulatorParallelTestRunner, 475 test_runner.DeviceTestRunner): 476 """Class for running tests on real device using xCode.""" 477 478 def __init__( 479 self, 480 app_path, 481 host_app_path, 482 out_dir, 483 release=False, 484 retries=1, 485 test_cases=None, 486 test_args=None, 487 env_vars=None, 488 ): 489 """Initializes a new instance of DeviceXcodeTestRunner class. 490 491 Args: 492 app_path: (str) A path to egtests_app. 493 host_app_path: (str) A path to the host app for EG2. 494 out_dir: (str) A directory to emit test data into. 495 retries: (int) A number to retry test run, will re-run only failed tests. 496 test_cases: (list) List of tests to be included in the test run. 497 None or [] to include all tests. 498 test_args: List of strings to pass as arguments to the test when 499 launching. 500 env_vars: List of environment variables to pass to the test itself. 501 502 Raises: 503 AppNotFoundError: If the given app does not exist. 504 DeviceDetectionError: If no device found. 505 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests. 506 XcodeVersionNotFoundError: If the given Xcode version does not exist. 507 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist. 508 """ 509 test_runner.DeviceTestRunner.__init__( 510 self, 511 app_path, 512 out_dir, 513 env_vars=env_vars, 514 retries=retries, 515 test_args=test_args, 516 test_cases=test_cases, 517 ) 518 self.shards = 1 # For tests on real devices shards=1 519 self.version = None 520 self.platform = None 521 self.host_app_path = None 522 if host_app_path != 'NO_PATH': 523 self.host_app_path = os.path.abspath(host_app_path) 524 self.homedir = '' 525 self.release = release 526 self.set_up() 527 self._init_sharding_data() 528 self.start_time = time.strftime('%Y-%m-%d-%H%M%S', time.localtime()) 529 self.test_results['path_delimiter'] = '/' 530 531 def set_up(self): 532 """Performs setup actions which must occur prior to every test launch.""" 533 self.uninstall_apps() 534 self.wipe_derived_data() 535 536 def tear_down(self): 537 """Performs cleanup actions which must occur after every test launch.""" 538 test_runner.DeviceTestRunner.tear_down(self) 539 540 def launch(self): 541 try: 542 return super(DeviceXcodeTestRunner, self).launch() 543 finally: 544 self.tear_down() 545