1# Copyright 2016 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 runners for iOS.""" 6 7import errno 8import signal 9import sys 10 11import collections 12import logging 13import os 14import psutil 15import re 16import shutil 17import subprocess 18import threading 19import time 20 21import file_util 22import gtest_utils 23import iossim_util 24import standard_json_util as sju 25import test_apps 26import xcode_log_parser 27import xcode_util 28import xctest_utils 29 30LOGGER = logging.getLogger(__name__) 31DERIVED_DATA = os.path.expanduser('~/Library/Developer/Xcode/DerivedData') 32READLINE_TIMEOUT = 180 33 34 35class Error(Exception): 36 """Base class for errors.""" 37 pass 38 39 40class OtoolError(Error): 41 """OTool non-zero error code""" 42 43 def __init__(self, code): 44 super(OtoolError, 45 self).__init__('otool returned a non-zero return code: %s' % code) 46 47 48class TestRunnerError(Error): 49 """Base class for TestRunner-related errors.""" 50 pass 51 52 53class DeviceError(TestRunnerError): 54 """Base class for physical device related errors.""" 55 pass 56 57 58class AppLaunchError(TestRunnerError): 59 """The app failed to launch.""" 60 pass 61 62 63class AppNotFoundError(TestRunnerError): 64 """The requested app was not found.""" 65 def __init__(self, app_path): 66 super(AppNotFoundError, self).__init__( 67 'App does not exist: %s' % app_path) 68 69 70class SystemAlertPresentError(DeviceError): 71 """System alert is shown on the device.""" 72 def __init__(self): 73 super(SystemAlertPresentError, self).__init__( 74 'System alert is shown on the device.') 75 76 77class DeviceDetectionError(DeviceError): 78 """Unexpected number of devices detected.""" 79 def __init__(self, udids): 80 super(DeviceDetectionError, self).__init__( 81 'Expected one device, found %s:\n%s' % (len(udids), '\n'.join(udids))) 82 83 84class DeviceRestartError(DeviceError): 85 """Error restarting a device.""" 86 def __init__(self): 87 super(DeviceRestartError, self).__init__('Error restarting a device') 88 89 90class PlugInsNotFoundError(TestRunnerError): 91 """The PlugIns directory was not found.""" 92 def __init__(self, plugins_dir): 93 super(PlugInsNotFoundError, self).__init__( 94 'PlugIns directory does not exist: %s' % plugins_dir) 95 96 97class SimulatorNotFoundError(TestRunnerError): 98 """The given simulator binary was not found.""" 99 def __init__(self, iossim_path): 100 super(SimulatorNotFoundError, self).__init__( 101 'Simulator does not exist: %s' % iossim_path) 102 103 104class TestDataExtractionError(DeviceError): 105 """Error extracting test data or crash reports from a device.""" 106 def __init__(self): 107 super(TestDataExtractionError, self).__init__('Failed to extract test data') 108 109 110class XcodeVersionNotFoundError(TestRunnerError): 111 """The requested version of Xcode was not found.""" 112 def __init__(self, xcode_version): 113 super(XcodeVersionNotFoundError, self).__init__( 114 'Xcode version not found: %s' % xcode_version) 115 116 117class XCTestConfigError(TestRunnerError): 118 """Error related with XCTest config.""" 119 120 def __init__(self, message): 121 super(XCTestConfigError, 122 self).__init__('Incorrect config related with XCTest: %s' % message) 123 124 125class XCTestPlugInNotFoundError(TestRunnerError): 126 """The .xctest PlugIn was not found.""" 127 def __init__(self, xctest_path): 128 super(XCTestPlugInNotFoundError, self).__init__( 129 'XCTest not found: %s' % xctest_path) 130 131 132class MacToolchainNotFoundError(TestRunnerError): 133 """The mac_toolchain is not specified.""" 134 def __init__(self, mac_toolchain): 135 super(MacToolchainNotFoundError, self).__init__( 136 'mac_toolchain is not specified or not found: "%s"' % mac_toolchain) 137 138 139class XcodePathNotFoundError(TestRunnerError): 140 """The path to Xcode.app is not specified.""" 141 def __init__(self, xcode_path): 142 super(XcodePathNotFoundError, self).__init__( 143 'xcode_path is not specified or does not exist: "%s"' % xcode_path) 144 145 146class ShardingDisabledError(TestRunnerError): 147 """Temporary error indicating that sharding is not yet implemented.""" 148 def __init__(self): 149 super(ShardingDisabledError, self).__init__( 150 'Sharding has not been implemented!') 151 152 153def get_device_ios_version(udid): 154 """Gets device iOS version. 155 156 Args: 157 udid: (str) iOS device UDID. 158 159 Returns: 160 Device UDID. 161 """ 162 return subprocess.check_output(['ideviceinfo', 163 '--udid', udid, 164 '-k', 'ProductVersion']).strip() 165 166 167def defaults_write(d, key, value): 168 """Run 'defaults write d key value' command. 169 170 Args: 171 d: (str) A dictionary. 172 key: (str) A key. 173 value: (str) A value. 174 """ 175 LOGGER.info('Run \'defaults write %s %s %s\'' % (d, key, value)) 176 subprocess.call(['defaults', 'write', d, key, value]) 177 178 179def defaults_delete(d, key): 180 """Run 'defaults delete d key' command. 181 182 Args: 183 d: (str) A dictionary. 184 key: (str) Key to delete. 185 """ 186 LOGGER.info('Run \'defaults delete %s %s\'' % (d, key)) 187 subprocess.call(['defaults', 'delete', d, key]) 188 189 190def terminate_process(proc, proc_name): 191 """Terminates the process. 192 193 If an error occurs ignore it, just print out a message. 194 195 Args: 196 proc: A subprocess to terminate. 197 proc_name: A name of process. 198 """ 199 try: 200 LOGGER.info('Killing hung process %s' % proc.pid) 201 proc.terminate() 202 attempts_to_kill = 3 203 ps = psutil.Process(proc.pid) 204 for _ in range(attempts_to_kill): 205 # Check whether proc.pid process is still alive. 206 if ps.is_running(): 207 LOGGER.info( 208 'Process %s is still alive! %s process might block it.', 209 psutil.Process(proc.pid).name(), proc_name) 210 running_processes = [ 211 p for p in psutil.process_iter() 212 # Use as_dict() to avoid API changes across versions of psutil. 213 if proc_name == p.as_dict(attrs=['name'])['name']] 214 if not running_processes: 215 LOGGER.debug('There are no running %s processes.', proc_name) 216 break 217 LOGGER.debug('List of running %s processes: %s' 218 % (proc_name, running_processes)) 219 # Killing running processes with proc_name 220 for p in running_processes: 221 p.send_signal(signal.SIGKILL) 222 psutil.wait_procs(running_processes) 223 else: 224 LOGGER.info('Process was killed!') 225 break 226 except OSError as ex: 227 LOGGER.info('Error while killing a process: %s' % ex) 228 229 230# TODO(crbug.com/1044812): Moved print_process_output to utils class. 231def print_process_output(proc, 232 proc_name=None, 233 parser=None, 234 timeout=READLINE_TIMEOUT): 235 """Logs process messages in console and waits until process is done. 236 237 Method waits until no output message and if no message for timeout seconds, 238 process will be terminated. 239 240 Args: 241 proc: A running process. 242 proc_name: (str) A process name that has to be killed 243 if no output occurs in specified timeout. Sometimes proc generates 244 child process that may block its parent and for such cases 245 proc_name refers to the name of child process. 246 If proc_name is not specified, process name will be used to kill process. 247 parser: A parser. 248 timeout: A timeout(in seconds) to subprocess.stdout.readline method. 249 """ 250 out = [] 251 if not proc_name: 252 proc_name = psutil.Process(proc.pid).name() 253 while True: 254 # subprocess.stdout.readline() might be stuck from time to time 255 # and tests fail because of TIMEOUT. 256 # Try to fix the issue by adding timer-thread for `timeout` seconds 257 # that will kill `frozen` running process if no new line is read 258 # and will finish test attempt. 259 # If new line appears in timeout, just cancel timer. 260 try: 261 timer = threading.Timer(timeout, terminate_process, [proc, proc_name]) 262 timer.start() 263 line = proc.stdout.readline() 264 finally: 265 timer.cancel() 266 if not line: 267 break 268 line = line.rstrip() 269 out.append(line) 270 if parser: 271 parser.ProcessLine(line) 272 LOGGER.info(line) 273 sys.stdout.flush() 274 LOGGER.debug('Finished print_process_output.') 275 return out 276 277 278def get_current_xcode_info(): 279 """Returns the current Xcode path, version, and build number. 280 281 Returns: 282 A dict with 'path', 'version', and 'build' keys. 283 'path': The absolute path to the Xcode installation. 284 'version': The Xcode version. 285 'build': The Xcode build version. 286 """ 287 try: 288 out = subprocess.check_output(['xcodebuild', '-version']).splitlines() 289 version, build_version = out[0].split(' ')[-1], out[1].split(' ')[-1] 290 path = subprocess.check_output(['xcode-select', '--print-path']).rstrip() 291 except subprocess.CalledProcessError: 292 version = build_version = path = None 293 294 return { 295 'path': path, 296 'version': version, 297 'build': build_version, 298 } 299 300 301class TestRunner(object): 302 """Base class containing common functionality.""" 303 304 def __init__( 305 self, 306 app_path, 307 out_dir, 308 env_vars=None, 309 retries=None, 310 shards=None, 311 test_args=None, 312 test_cases=None, 313 xctest=False, 314 ): 315 """Initializes a new instance of this class. 316 317 Args: 318 app_path: Path to the compiled .app to run. 319 out_dir: Directory to emit test data into. 320 env_vars: List of environment variables to pass to the test itself. 321 retries: Number of times to retry failed test cases. 322 test_args: List of strings to pass as arguments to the test when 323 launching. 324 test_cases: List of tests to be included in the test run. None or [] to 325 include all tests. 326 xctest: Whether or not this is an XCTest. 327 328 Raises: 329 AppNotFoundError: If the given app does not exist. 330 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests. 331 XcodeVersionNotFoundError: If the given Xcode version does not exist. 332 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist. 333 """ 334 app_path = os.path.abspath(app_path) 335 if not os.path.exists(app_path): 336 raise AppNotFoundError(app_path) 337 338 xcode_info = get_current_xcode_info() 339 LOGGER.info('Using Xcode version %s build %s at %s', 340 xcode_info['version'], 341 xcode_info['build'], 342 xcode_info['path']) 343 344 if not os.path.exists(out_dir): 345 os.makedirs(out_dir) 346 347 self.app_name = os.path.splitext(os.path.split(app_path)[-1])[0] 348 self.app_path = app_path 349 self.cfbundleid = test_apps.get_bundle_id(app_path) 350 self.env_vars = env_vars or [] 351 self.logs = collections.OrderedDict() 352 self.out_dir = out_dir 353 self.retries = retries or 0 354 self.shards = shards or 1 355 self.test_args = test_args or [] 356 self.test_cases = test_cases or [] 357 self.xctest_path = '' 358 self.xctest = xctest 359 360 self.test_results = {} 361 self.test_results['version'] = 3 362 self.test_results['path_delimiter'] = '.' 363 self.test_results['seconds_since_epoch'] = int(time.time()) 364 # This will be overwritten when the tests complete successfully. 365 self.test_results['interrupted'] = True 366 367 if self.xctest: 368 plugins_dir = os.path.join(self.app_path, 'PlugIns') 369 if not os.path.exists(plugins_dir): 370 raise PlugInsNotFoundError(plugins_dir) 371 for plugin in os.listdir(plugins_dir): 372 if plugin.endswith('.xctest'): 373 self.xctest_path = os.path.join(plugins_dir, plugin) 374 if not os.path.exists(self.xctest_path): 375 raise XCTestPlugInNotFoundError(self.xctest_path) 376 377 def get_launch_command(self, test_app, out_dir, destination, shards=1): 378 """Returns the command that can be used to launch the test app. 379 380 Args: 381 test_app: An app that stores data about test required to run. 382 out_dir: (str) A path for results. 383 destination: (str) A destination of device/simulator. 384 shards: (int) How many shards the tests should be divided into. 385 386 Returns: 387 A list of strings forming the command to launch the test. 388 """ 389 raise NotImplementedError 390 391 def get_launch_env(self): 392 """Returns a dict of environment variables to use to launch the test app. 393 394 Returns: 395 A dict of environment variables. 396 """ 397 return os.environ.copy() 398 399 def start_proc(self, cmd): 400 """Starts a process with cmd command and os.environ. 401 402 Returns: 403 An instance of process. 404 """ 405 return subprocess.Popen( 406 cmd, 407 env=self.get_launch_env(), 408 stdout=subprocess.PIPE, 409 stderr=subprocess.STDOUT, 410 ) 411 412 def shutdown_and_restart(self): 413 """Restart a device or relaunch a simulator.""" 414 pass 415 416 def set_up(self): 417 """Performs setup actions which must occur prior to every test launch.""" 418 raise NotImplementedError 419 420 def tear_down(self): 421 """Performs cleanup actions which must occur after every test launch.""" 422 raise NotImplementedError 423 424 def screenshot_desktop(self): 425 """Saves a screenshot of the desktop in the output directory.""" 426 subprocess.check_call([ 427 'screencapture', 428 os.path.join(self.out_dir, 'desktop_%s.png' % time.time()), 429 ]) 430 431 def retrieve_derived_data(self): 432 """Retrieves the contents of DerivedData""" 433 # DerivedData contains some logs inside workspace-specific directories. 434 # Since we don't control the name of the workspace or project, most of 435 # the directories are just called "temporary", making it hard to tell 436 # which directory we need to retrieve. Instead we just delete the 437 # entire contents of this directory before starting and return the 438 # entire contents after the test is over. 439 if os.path.exists(DERIVED_DATA): 440 os.mkdir(os.path.join(self.out_dir, 'DerivedData')) 441 derived_data = os.path.join(self.out_dir, 'DerivedData') 442 for directory in os.listdir(DERIVED_DATA): 443 LOGGER.info('Copying %s directory', directory) 444 shutil.move(os.path.join(DERIVED_DATA, directory), derived_data) 445 446 def wipe_derived_data(self): 447 """Removes the contents of Xcode's DerivedData directory.""" 448 if os.path.exists(DERIVED_DATA): 449 shutil.rmtree(DERIVED_DATA) 450 os.mkdir(DERIVED_DATA) 451 452 def process_xcresult_dir(self): 453 """Copies artifacts & diagnostic logs, zips and removes .xcresult dir.""" 454 # .xcresult dir only exists when using Xcode 11+ and running as XCTest. 455 if not xcode_util.using_xcode_11_or_higher() or not self.xctest: 456 LOGGER.info('Skip processing xcresult directory.') 457 458 xcresult_paths = [] 459 # Warning: This piece of code assumes .xcresult folder is directly under 460 # self.out_dir. This is true for TestRunner subclasses in this file. 461 # xcresult folder path is whatever passed in -resultBundlePath to xcodebuild 462 # command appended with '.xcresult' suffix. 463 for filename in os.listdir(self.out_dir): 464 full_path = os.path.join(self.out_dir, filename) 465 if full_path.endswith('.xcresult') and os.path.isdir(full_path): 466 xcresult_paths.append(full_path) 467 468 log_parser = xcode_log_parser.get_parser() 469 for xcresult in xcresult_paths: 470 # This is what was passed in -resultBundlePath to xcodebuild command. 471 result_bundle_path = os.path.splitext(xcresult)[0] 472 log_parser.copy_artifacts(result_bundle_path) 473 log_parser.export_diagnostic_data(result_bundle_path) 474 # result_bundle_path is a symlink to xcresult directory. 475 if os.path.islink(result_bundle_path): 476 os.unlink(result_bundle_path) 477 file_util.zip_and_remove_folder(xcresult) 478 479 def run_tests(self, cmd=None): 480 """Runs passed-in tests. 481 482 Args: 483 cmd: Command to run tests. 484 485 Return: 486 out: (list) List of strings of subprocess's output. 487 returncode: (int) Return code of subprocess. 488 """ 489 raise NotImplementedError 490 491 def set_sigterm_handler(self, handler): 492 """Sets the SIGTERM handler for the test runner. 493 494 This is its own separate function so it can be mocked in tests. 495 496 Args: 497 handler: The handler to be called when a SIGTERM is caught 498 499 Returns: 500 The previous SIGTERM handler for the test runner. 501 """ 502 LOGGER.debug('Setting sigterm handler.') 503 return signal.signal(signal.SIGTERM, handler) 504 505 def handle_sigterm(self, proc): 506 """Handles a SIGTERM sent while a test command is executing. 507 508 Will SIGKILL the currently executing test process, then 509 attempt to exit gracefully. 510 511 Args: 512 proc: The currently executing test process. 513 """ 514 LOGGER.warning('Sigterm caught during test run. Killing test process.') 515 proc.kill() 516 517 def _run(self, cmd, shards=1): 518 """Runs the specified command, parsing GTest output. 519 520 Args: 521 cmd: List of strings forming the command to run. 522 523 Returns: 524 GTestResult instance. 525 """ 526 result = gtest_utils.GTestResult(cmd) 527 528 parser = gtest_utils.GTestLogParser() 529 530 # TODO(crbug.com/812705): Implement test sharding for unit tests. 531 # TODO(crbug.com/812712): Use thread pool for DeviceTestRunner as well. 532 proc = self.start_proc(cmd) 533 old_handler = self.set_sigterm_handler( 534 lambda _signum, _frame: self.handle_sigterm(proc)) 535 print_process_output(proc, 'xcodebuild', parser) 536 LOGGER.info('Waiting for test process to terminate.') 537 proc.wait() 538 LOGGER.info('Test process terminated.') 539 self.set_sigterm_handler(old_handler) 540 sys.stdout.flush() 541 LOGGER.debug('Stdout flushed after test process.') 542 returncode = proc.returncode 543 544 LOGGER.debug('Processing test results.') 545 for test in parser.FailedTests(include_flaky=True): 546 # Test cases are named as <test group>.<test case>. If the test case 547 # is prefixed with "FLAKY_", it should be reported as flaked not failed. 548 if '.' in test and test.split('.', 1)[1].startswith('FLAKY_'): 549 result.flaked_tests[test] = parser.FailureDescription(test) 550 else: 551 result.failed_tests[test] = parser.FailureDescription(test) 552 553 result.passed_tests.extend(parser.PassedTests(include_flaky=True)) 554 555 # Only GTest outputs compiled tests in a json file. 556 result.disabled_tests_from_compiled_tests_file.extend( 557 parser.DisabledTestsFromCompiledTestsFile()) 558 559 LOGGER.info('%s returned %s\n', cmd[0], returncode) 560 561 # xcodebuild can return 5 if it exits noncleanly even if all tests passed. 562 # Therefore we cannot rely on process exit code to determine success. 563 result.finalize(returncode, parser.CompletedWithoutFailure()) 564 return result 565 566 def launch(self): 567 """Launches the test app.""" 568 self.set_up() 569 destination = 'id=%s' % self.udid 570 # When current |launch| method is invoked, this is running a unit test 571 # target. For simulators, '--xctest' is passed to test runner scripts to 572 # make it run XCTest based unit test. 573 if self.xctest: 574 # TODO(crbug.com/1085603): Pass in test runner an arg to determine if it's 575 # device test or simulator test and test the arg here. 576 if self.__class__.__name__ == 'SimulatorTestRunner': 577 test_app = test_apps.SimulatorXCTestUnitTestsApp( 578 self.app_path, 579 included_tests=self.test_cases, 580 env_vars=self.env_vars, 581 test_args=self.test_args) 582 elif self.__class__.__name__ == 'DeviceTestRunner': 583 test_app = test_apps.DeviceXCTestUnitTestsApp( 584 self.app_path, 585 included_tests=self.test_cases, 586 env_vars=self.env_vars, 587 test_args=self.test_args) 588 else: 589 raise XCTestConfigError('Wrong config. TestRunner.launch() called from' 590 ' an unexpected class.') 591 else: 592 test_app = test_apps.GTestsApp( 593 self.app_path, 594 included_tests=self.test_cases, 595 env_vars=self.env_vars, 596 test_args=self.test_args) 597 out_dir = os.path.join(self.out_dir, 'TestResults') 598 cmd = self.get_launch_command(test_app, out_dir, destination, self.shards) 599 try: 600 result = self._run(cmd=cmd, shards=self.shards or 1) 601 if result.crashed and not result.crashed_test: 602 # If the app crashed but not during any particular test case, assume 603 # it crashed on startup. Try one more time. 604 self.shutdown_and_restart() 605 LOGGER.warning('Crashed on startup, retrying...\n') 606 out_dir = os.path.join(self.out_dir, 'retry_after_crash_on_startup') 607 cmd = self.get_launch_command(test_app, out_dir, destination, 608 self.shards) 609 result = self._run(cmd) 610 611 if result.crashed and not result.crashed_test: 612 raise AppLaunchError 613 614 passed = result.passed_tests 615 failed = result.failed_tests 616 flaked = result.flaked_tests 617 disabled = result.disabled_tests_from_compiled_tests_file 618 619 try: 620 while result.crashed and result.crashed_test: 621 # If the app crashes during a specific test case, then resume at the 622 # next test case. This is achieved by filtering out every test case 623 # which has already run. 624 LOGGER.warning('Crashed during %s, resuming...\n', 625 result.crashed_test) 626 test_app.excluded_tests = passed + failed.keys() + flaked.keys() 627 retry_out_dir = os.path.join( 628 self.out_dir, 'retry_after_crash_%d' % int(time.time())) 629 result = self._run( 630 self.get_launch_command( 631 test_app, os.path.join(retry_out_dir, str(int(time.time()))), 632 destination)) 633 passed.extend(result.passed_tests) 634 failed.update(result.failed_tests) 635 flaked.update(result.flaked_tests) 636 if not disabled: 637 disabled = result.disabled_tests_from_compiled_tests_file 638 639 except OSError as e: 640 if e.errno == errno.E2BIG: 641 LOGGER.error('Too many test cases to resume.') 642 else: 643 raise 644 645 # Instantiate this after crash retries so that all tests have a first 646 # pass before entering the retry block below. 647 # For each retry that passes, we want to mark it separately as passed 648 # (ie/ "FAIL PASS"), with is_flaky=True. 649 # TODO(crbug.com/1132476): Report failed GTest logs to ResultSink. 650 output = sju.StdJson(passed=passed, failed=failed, flaked=flaked) 651 652 # Retry failed test cases. 653 retry_results = {} 654 test_app.excluded_tests = [] 655 if self.retries and failed: 656 LOGGER.warning('%s tests failed and will be retried.\n', len(failed)) 657 for i in xrange(self.retries): 658 for test in failed.keys(): 659 LOGGER.info('Retry #%s for %s.\n', i + 1, test) 660 test_app.included_tests = [test] 661 retry_out_dir = os.path.join(self.out_dir, test + '_failed', 662 'retry_%d' % i) 663 retry_result = self._run( 664 self.get_launch_command(test_app, retry_out_dir, destination)) 665 # If the test passed on retry, consider it flake instead of failure. 666 if test in retry_result.passed_tests: 667 flaked[test] = failed.pop(test) 668 output.mark_passed(test, flaky=True) 669 # Save the result of the latest run for each test. 670 retry_results[test] = retry_result 671 672 output.mark_all_skipped(disabled) 673 output.finalize() 674 675 # Build test_results.json. 676 # Check if if any of the retries crashed in addition to the original run. 677 interrupted = (result.crashed or 678 any([r.crashed for r in retry_results.values()])) 679 self.test_results['interrupted'] = interrupted 680 self.test_results['num_failures_by_type'] = { 681 'FAIL': len(failed) + len(flaked), 682 'PASS': len(passed), 683 } 684 685 self.test_results['tests'] = output.tests 686 687 self.logs['passed tests'] = passed 688 if disabled: 689 self.logs['disabled tests'] = disabled 690 if flaked: 691 self.logs['flaked tests'] = flaked 692 if failed: 693 self.logs['failed tests'] = failed 694 for test, log_lines in failed.iteritems(): 695 self.logs[test] = log_lines 696 for test, log_lines in flaked.iteritems(): 697 self.logs[test] = log_lines 698 699 return not failed and not interrupted 700 finally: 701 self.tear_down() 702 703 704class SimulatorTestRunner(TestRunner): 705 """Class for running tests on iossim.""" 706 707 def __init__( 708 self, 709 app_path, 710 iossim_path, 711 platform, 712 version, 713 out_dir, 714 env_vars=None, 715 retries=None, 716 shards=None, 717 test_args=None, 718 test_cases=None, 719 use_clang_coverage=False, 720 wpr_tools_path='', 721 xctest=False, 722 ): 723 """Initializes a new instance of this class. 724 725 Args: 726 app_path: Path to the compiled .app or .ipa to run. 727 iossim_path: Path to the compiled iossim binary to use. 728 platform: Name of the platform to simulate. Supported values can be found 729 by running "iossim -l". e.g. "iPhone 5s", "iPad Retina". 730 version: Version of iOS the platform should be running. Supported values 731 can be found by running "iossim -l". e.g. "9.3", "8.2", "7.1". 732 out_dir: Directory to emit test data into. 733 env_vars: List of environment variables to pass to the test itself. 734 retries: Number of times to retry failed test cases. 735 test_args: List of strings to pass as arguments to the test when 736 launching. 737 test_cases: List of tests to be included in the test run. None or [] to 738 include all tests. 739 use_clang_coverage: Whether code coverage is enabled in this run. 740 wpr_tools_path: Path to pre-installed WPR-related tools 741 xctest: Whether or not this is an XCTest. 742 743 Raises: 744 AppNotFoundError: If the given app does not exist. 745 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests. 746 XcodeVersionNotFoundError: If the given Xcode version does not exist. 747 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist. 748 """ 749 super(SimulatorTestRunner, self).__init__( 750 app_path, 751 out_dir, 752 env_vars=env_vars, 753 retries=retries, 754 test_args=test_args, 755 test_cases=test_cases, 756 xctest=xctest, 757 ) 758 759 iossim_path = os.path.abspath(iossim_path) 760 if not os.path.exists(iossim_path): 761 raise SimulatorNotFoundError(iossim_path) 762 763 self.homedir = '' 764 self.iossim_path = iossim_path 765 self.platform = platform 766 self.start_time = None 767 self.version = version 768 self.shards = shards 769 self.wpr_tools_path = wpr_tools_path 770 self.udid = iossim_util.get_simulator(self.platform, self.version) 771 self.use_clang_coverage = use_clang_coverage 772 773 @staticmethod 774 def kill_simulators(): 775 """Kills all running simulators.""" 776 try: 777 LOGGER.info('Killing simulators.') 778 subprocess.check_call([ 779 'pkill', 780 '-9', 781 '-x', 782 # The simulator's name varies by Xcode version. 783 'com.apple.CoreSimulator.CoreSimulatorService', # crbug.com/684305 784 'iPhone Simulator', # Xcode 5 785 'iOS Simulator', # Xcode 6 786 'Simulator', # Xcode 7+ 787 'simctl', # https://crbug.com/637429 788 'xcodebuild', # https://crbug.com/684305 789 ]) 790 # If a signal was sent, wait for the simulators to actually be killed. 791 time.sleep(5) 792 except subprocess.CalledProcessError as e: 793 if e.returncode != 1: 794 # Ignore a 1 exit code (which means there were no simulators to kill). 795 raise 796 797 def wipe_simulator(self): 798 """Wipes the simulator.""" 799 iossim_util.wipe_simulator_by_udid(self.udid) 800 801 def get_home_directory(self): 802 """Returns the simulator's home directory.""" 803 return iossim_util.get_home_directory(self.platform, self.version) 804 805 def set_up(self): 806 """Performs setup actions which must occur prior to every test launch.""" 807 self.kill_simulators() 808 self.wipe_simulator() 809 self.wipe_derived_data() 810 self.homedir = self.get_home_directory() 811 # Crash reports have a timestamp in their file name, formatted as 812 # YYYY-MM-DD-HHMMSS. Save the current time in the same format so 813 # we can compare and fetch crash reports from this run later on. 814 self.start_time = time.strftime('%Y-%m-%d-%H%M%S', time.localtime()) 815 816 def extract_test_data(self): 817 """Extracts data emitted by the test.""" 818 if hasattr(self, 'use_clang_coverage') and self.use_clang_coverage: 819 file_util.move_raw_coverage_data(self.udid, self.out_dir) 820 821 # Find the Documents directory of the test app. The app directory names 822 # don't correspond with any known information, so we have to examine them 823 # all until we find one with a matching CFBundleIdentifier. 824 apps_dir = os.path.join( 825 self.homedir, 'Containers', 'Data', 'Application') 826 if os.path.exists(apps_dir): 827 for appid_dir in os.listdir(apps_dir): 828 docs_dir = os.path.join(apps_dir, appid_dir, 'Documents') 829 metadata_plist = os.path.join( 830 apps_dir, 831 appid_dir, 832 '.com.apple.mobile_container_manager.metadata.plist', 833 ) 834 if os.path.exists(docs_dir) and os.path.exists(metadata_plist): 835 cfbundleid = subprocess.check_output([ 836 '/usr/libexec/PlistBuddy', 837 '-c', 'Print:MCMMetadataIdentifier', 838 metadata_plist, 839 ]).rstrip() 840 if cfbundleid == self.cfbundleid: 841 shutil.copytree(docs_dir, os.path.join(self.out_dir, 'Documents')) 842 return 843 844 def retrieve_crash_reports(self): 845 """Retrieves crash reports produced by the test.""" 846 # A crash report's naming scheme is [app]_[timestamp]_[hostname].crash. 847 # e.g. net_unittests_2014-05-13-15-0900_vm1-a1.crash. 848 crash_reports_dir = os.path.expanduser(os.path.join( 849 '~', 'Library', 'Logs', 'DiagnosticReports')) 850 851 if not os.path.exists(crash_reports_dir): 852 return 853 854 for crash_report in os.listdir(crash_reports_dir): 855 report_name, ext = os.path.splitext(crash_report) 856 if report_name.startswith(self.app_name) and ext == '.crash': 857 report_time = report_name[len(self.app_name) + 1:].split('_')[0] 858 859 # The timestamp format in a crash report is big-endian and therefore 860 # a straight string comparison works. 861 if report_time > self.start_time: 862 with open(os.path.join(crash_reports_dir, crash_report)) as f: 863 self.logs['crash report (%s)' % report_time] = ( 864 f.read().splitlines()) 865 866 def tear_down(self): 867 """Performs cleanup actions which must occur after every test launch.""" 868 LOGGER.debug('Extracting test data.') 869 self.extract_test_data() 870 LOGGER.debug('Retrieving crash reports.') 871 self.retrieve_crash_reports() 872 LOGGER.debug('Retrieving derived data.') 873 self.retrieve_derived_data() 874 LOGGER.debug('Processing xcresult folder.') 875 self.process_xcresult_dir() 876 LOGGER.debug('Making desktop screenshots.') 877 self.screenshot_desktop() 878 LOGGER.debug('Killing simulators.') 879 self.kill_simulators() 880 LOGGER.debug('Wiping simulator.') 881 self.wipe_simulator() 882 LOGGER.debug('Deleting simulator.') 883 self.deleteSimulator(self.udid) 884 if os.path.exists(self.homedir): 885 shutil.rmtree(self.homedir, ignore_errors=True) 886 self.homedir = '' 887 LOGGER.debug('End of tear_down.') 888 889 def run_tests(self, cmd): 890 """Runs passed-in tests. Builds a command and create a simulator to 891 run tests. 892 Args: 893 cmd: A running command. 894 895 Return: 896 out: (list) List of strings of subprocess's output. 897 returncode: (int) Return code of subprocess. 898 """ 899 proc = self.start_proc(cmd) 900 out = print_process_output(proc, 'xcodebuild', 901 xctest_utils.XCTestLogParser()) 902 self.deleteSimulator(self.udid) 903 return (out, proc.returncode) 904 905 def getSimulator(self): 906 """Gets a simulator or creates a new one by device types and runtimes. 907 Returns the udid for the created simulator instance. 908 909 Returns: 910 An udid of a simulator device. 911 """ 912 return iossim_util.get_simulator(self.platform, self.version) 913 914 def deleteSimulator(self, udid=None): 915 """Removes dynamically created simulator devices.""" 916 if udid: 917 iossim_util.delete_simulator_by_udid(udid) 918 919 def get_launch_command(self, test_app, out_dir, destination, shards=1): 920 """Returns the command that can be used to launch the test app. 921 922 Args: 923 test_app: An app that stores data about test required to run. 924 out_dir: (str) A path for results. 925 destination: (str) A destination of device/simulator. 926 shards: (int) How many shards the tests should be divided into. 927 928 Returns: 929 A list of strings forming the command to launch the test. 930 """ 931 return test_app.command(out_dir, destination, shards) 932 933 def get_launch_env(self): 934 """Returns a dict of environment variables to use to launch the test app. 935 936 Returns: 937 A dict of environment variables. 938 """ 939 env = super(SimulatorTestRunner, self).get_launch_env() 940 if self.xctest: 941 env['NSUnbufferedIO'] = 'YES' 942 return env 943 944 945class DeviceTestRunner(TestRunner): 946 """Class for running tests on devices.""" 947 948 def __init__( 949 self, 950 app_path, 951 out_dir, 952 env_vars=None, 953 restart=False, 954 retries=None, 955 shards=None, 956 test_args=None, 957 test_cases=None, 958 xctest=False, 959 ): 960 """Initializes a new instance of this class. 961 962 Args: 963 app_path: Path to the compiled .app to run. 964 out_dir: Directory to emit test data into. 965 env_vars: List of environment variables to pass to the test itself. 966 restart: Whether or not restart device when test app crashes on startup. 967 retries: Number of times to retry failed test cases. 968 test_args: List of strings to pass as arguments to the test when 969 launching. 970 test_cases: List of tests to be included in the test run. None or [] to 971 include all tests. 972 xctest: Whether or not this is an XCTest. 973 974 Raises: 975 AppNotFoundError: If the given app does not exist. 976 PlugInsNotFoundError: If the PlugIns directory does not exist for XCTests. 977 XcodeVersionNotFoundError: If the given Xcode version does not exist. 978 XCTestPlugInNotFoundError: If the .xctest PlugIn does not exist. 979 """ 980 super(DeviceTestRunner, self).__init__( 981 app_path, 982 out_dir, 983 env_vars=env_vars, 984 retries=retries, 985 test_args=test_args, 986 test_cases=test_cases, 987 xctest=xctest, 988 ) 989 990 self.udid = subprocess.check_output(['idevice_id', '--list']).rstrip() 991 if len(self.udid.splitlines()) != 1: 992 raise DeviceDetectionError(self.udid) 993 994 self.restart = restart 995 996 def uninstall_apps(self): 997 """Uninstalls all apps found on the device.""" 998 for app in self.get_installed_packages(): 999 cmd = ['ideviceinstaller', '--udid', self.udid, '--uninstall', app] 1000 print_process_output(self.start_proc(cmd)) 1001 1002 def install_app(self): 1003 """Installs the app.""" 1004 cmd = ['ideviceinstaller', '--udid', self.udid, '--install', self.app_path] 1005 print_process_output(self.start_proc(cmd)) 1006 1007 def get_installed_packages(self): 1008 """Gets a list of installed packages on a device. 1009 1010 Returns: 1011 A list of installed packages on a device. 1012 """ 1013 cmd = ['idevicefs', '--udid', self.udid, 'ls', '@'] 1014 return print_process_output(self.start_proc(cmd)) 1015 1016 def set_up(self): 1017 """Performs setup actions which must occur prior to every test launch.""" 1018 self.uninstall_apps() 1019 self.wipe_derived_data() 1020 self.install_app() 1021 1022 def extract_test_data(self): 1023 """Extracts data emitted by the test.""" 1024 cmd = [ 1025 'idevicefs', 1026 '--udid', self.udid, 1027 'pull', 1028 '@%s/Documents' % self.cfbundleid, 1029 os.path.join(self.out_dir, 'Documents'), 1030 ] 1031 try: 1032 print_process_output(self.start_proc(cmd)) 1033 except subprocess.CalledProcessError: 1034 raise TestDataExtractionError() 1035 1036 def shutdown_and_restart(self): 1037 """Restart the device, wait for two minutes.""" 1038 # TODO(crbug.com/760399): swarming bot ios 11 devices turn to be unavailable 1039 # in a few hours unexpectedly, which is assumed as an ios beta issue. Should 1040 # remove this method once the bug is fixed. 1041 if self.restart: 1042 LOGGER.info('Restarting device, wait for two minutes.') 1043 try: 1044 subprocess.check_call( 1045 ['idevicediagnostics', 'restart', '--udid', self.udid]) 1046 except subprocess.CalledProcessError: 1047 raise DeviceRestartError() 1048 time.sleep(120) 1049 1050 def retrieve_crash_reports(self): 1051 """Retrieves crash reports produced by the test.""" 1052 logs_dir = os.path.join(self.out_dir, 'Logs') 1053 os.mkdir(logs_dir) 1054 cmd = [ 1055 'idevicecrashreport', 1056 '--extract', 1057 '--udid', self.udid, 1058 logs_dir, 1059 ] 1060 try: 1061 print_process_output(self.start_proc(cmd)) 1062 except subprocess.CalledProcessError: 1063 # TODO(crbug.com/828951): Raise the exception when the bug is fixed. 1064 LOGGER.warning('Failed to retrieve crash reports from device.') 1065 1066 def tear_down(self): 1067 """Performs cleanup actions which must occur after every test launch.""" 1068 self.screenshot_desktop() 1069 self.retrieve_derived_data() 1070 self.extract_test_data() 1071 self.process_xcresult_dir() 1072 self.retrieve_crash_reports() 1073 self.uninstall_apps() 1074 1075 def get_launch_command(self, test_app, out_dir, destination, shards=1): 1076 """Returns the command that can be used to launch the test app. 1077 1078 Args: 1079 test_app: An app that stores data about test required to run. 1080 out_dir: (str) A path for results. 1081 destination: (str) A destination of device/simulator. 1082 shards: (int) How many shards the tests should be divided into. 1083 1084 Returns: 1085 A list of strings forming the command to launch the test. 1086 """ 1087 if self.xctest: 1088 return test_app.command(out_dir, destination, shards) 1089 1090 cmd = [ 1091 'idevice-app-runner', 1092 '--udid', self.udid, 1093 '--start', self.cfbundleid, 1094 ] 1095 args = [] 1096 gtest_filter = [] 1097 kif_filter = [] 1098 1099 if test_app.included_tests: 1100 kif_filter = test_apps.get_kif_test_filter(test_app.included_tests, 1101 invert=False) 1102 gtest_filter = test_apps.get_gtest_filter(test_app.included_tests, 1103 invert=False) 1104 elif test_app.excluded_tests: 1105 kif_filter = test_apps.get_kif_test_filter(test_app.excluded_tests, 1106 invert=True) 1107 gtest_filter = test_apps.get_gtest_filter(test_app.excluded_tests, 1108 invert=True) 1109 1110 if kif_filter: 1111 cmd.extend(['-D', 'GKIF_SCENARIO_FILTER=%s' % kif_filter]) 1112 if gtest_filter: 1113 args.append('--gtest_filter=%s' % gtest_filter) 1114 1115 for env_var in self.env_vars: 1116 cmd.extend(['-D', env_var]) 1117 1118 if args or self.test_args: 1119 cmd.append('--args') 1120 cmd.extend(self.test_args) 1121 cmd.extend(args) 1122 1123 return cmd 1124 1125 def get_launch_env(self): 1126 """Returns a dict of environment variables to use to launch the test app. 1127 1128 Returns: 1129 A dict of environment variables. 1130 """ 1131 env = super(DeviceTestRunner, self).get_launch_env() 1132 if self.xctest: 1133 env['NSUnbufferedIO'] = 'YES' 1134 # e.g. ios_web_shell_egtests 1135 env['APP_TARGET_NAME'] = os.path.splitext( 1136 os.path.basename(self.app_path))[0] 1137 # e.g. ios_web_shell_egtests_module 1138 env['TEST_TARGET_NAME'] = env['APP_TARGET_NAME'] + '_module' 1139 return env 1140