| # Copyright 2016 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 runners for iOS.""" | 
 |  | 
 | import argparse | 
 | import collections | 
 | import errno | 
 | import os | 
 | import shutil | 
 | import subprocess | 
 | import sys | 
 | import tempfile | 
 | import time | 
 |  | 
 | import find_xcode | 
 | import gtest_utils | 
 | import xctest_utils | 
 |  | 
 |  | 
 | DERIVED_DATA = os.path.expanduser('~/Library/Developer/Xcode/DerivedData') | 
 |  | 
 |  | 
 | XCTEST_PROJECT = os.path.abspath(os.path.join( | 
 |   os.path.dirname(__file__), | 
 |   'TestProject', | 
 |   'TestProject.xcodeproj', | 
 | )) | 
 |  | 
 | XCTEST_SCHEME = 'TestProject' | 
 |  | 
 |  | 
 | class Error(Exception): | 
 |   """Base class for errors.""" | 
 |   pass | 
 |  | 
 |  | 
 | class TestRunnerError(Error): | 
 |   """Base class for TestRunner-related errors.""" | 
 |   pass | 
 |  | 
 |  | 
 | class AppLaunchError(TestRunnerError): | 
 |   """The app failed to launch.""" | 
 |   pass | 
 |  | 
 |  | 
 | class AppNotFoundError(TestRunnerError): | 
 |   """The requested app was not found.""" | 
 |   def __init__(self, app_path): | 
 |     super(AppNotFoundError, self).__init__( | 
 |       'App does not exist: %s' % app_path) | 
 |  | 
 |  | 
 | class DeviceDetectionError(TestRunnerError): | 
 |   """Unexpected number of devices detected.""" | 
 |   def __init__(self, udids): | 
 |     super(DeviceDetectionError, self).__init__( | 
 |       'Expected one device, found %s:\n%s' % (len(udids), '\n'.join(udids))) | 
 |  | 
 |  | 
 | class PlugInsNotFoundError(TestRunnerError): | 
 |   """The PlugIns directory was not found.""" | 
 |   def __init__(self, plugins_dir): | 
 |     super(PlugInsNotFoundError, self).__init__( | 
 |       'PlugIns directory does not exist: %s' % plugins_dir) | 
 |  | 
 |  | 
 | class SimulatorNotFoundError(TestRunnerError): | 
 |   """The given simulator binary was not found.""" | 
 |   def __init__(self, iossim_path): | 
 |     super(SimulatorNotFoundError, self).__init__( | 
 |         'Simulator does not exist: %s' % iossim_path) | 
 |  | 
 |  | 
 | class TestDataExtractionError(TestRunnerError): | 
 |   """Error extracting test data or crash reports from a device.""" | 
 |   def __init__(self): | 
 |     super(TestDataExtractionError, self).__init__('Failed to extract test data') | 
 |  | 
 |  | 
 | class XcodeVersionNotFoundError(TestRunnerError): | 
 |   """The requested version of Xcode was not found.""" | 
 |   def __init__(self, xcode_version): | 
 |     super(XcodeVersionNotFoundError, self).__init__( | 
 |         'Xcode version not found: %s', xcode_version) | 
 |  | 
 |  | 
 | class XCTestPlugInNotFoundError(TestRunnerError): | 
 |   """The .xctest PlugIn was not found.""" | 
 |   def __init__(self, xctest_path): | 
 |     super(XCTestPlugInNotFoundError, self).__init__( | 
 |         'XCTest not found: %s', xctest_path) | 
 |  | 
 |  | 
 | def get_kif_test_filter(tests, invert=False): | 
 |   """Returns the KIF test filter to filter the given test cases. | 
 |  | 
 |   Args: | 
 |     tests: List of test cases to filter. | 
 |     invert: Whether to invert the filter or not. Inverted, the filter will match | 
 |       everything except the given test cases. | 
 |  | 
 |   Returns: | 
 |     A string which can be supplied to GKIF_SCENARIO_FILTER. | 
 |   """ | 
 |   # A pipe-separated list of test cases with the "KIF." prefix omitted. | 
 |   # e.g. NAME:a|b|c matches KIF.a, KIF.b, KIF.c. | 
 |   # e.g. -NAME:a|b|c matches everything except KIF.a, KIF.b, KIF.c. | 
 |   test_filter = '|'.join(test.split('KIF.', 1)[-1] for test in tests) | 
 |   if invert: | 
 |     return '-NAME:%s' % test_filter | 
 |   return 'NAME:%s' % test_filter | 
 |  | 
 |  | 
 | def get_gtest_filter(tests, invert=False): | 
 |   """Returns the GTest filter to filter the given test cases. | 
 |  | 
 |   Args: | 
 |     tests: List of test cases to filter. | 
 |     invert: Whether to invert the filter or not. Inverted, the filter will match | 
 |       everything except the given test cases. | 
 |  | 
 |   Returns: | 
 |     A string which can be supplied to --gtest_filter. | 
 |   """ | 
 |   # A colon-separated list of tests cases. | 
 |   # e.g. a:b:c matches a, b, c. | 
 |   # e.g. -a:b:c matches everything except a, b, c. | 
 |   test_filter = ':'.join(test for test in tests) | 
 |   if invert: | 
 |     return '-%s' % test_filter | 
 |   return test_filter | 
 |  | 
 |  | 
 | class TestRunner(object): | 
 |   """Base class containing common functionality.""" | 
 |  | 
 |   def __init__( | 
 |     self, | 
 |     app_path, | 
 |     xcode_version, | 
 |     out_dir, | 
 |     env_vars=None, | 
 |     test_args=None, | 
 |     xctest=False, | 
 |   ): | 
 |     """Initializes a new instance of this class. | 
 |  | 
 |     Args: | 
 |       app_path: Path to the compiled .app to run. | 
 |       xcode_version: Version of Xcode to use when running the test. | 
 |       out_dir: Directory to emit test data into. | 
 |       env_vars: List of environment variables to pass to the test itself. | 
 |       test_args: List of strings to pass as arguments to the test when | 
 |         launching. | 
 |       xctest: Whether or not this is an XCTest. | 
 |  | 
 |     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. | 
 |     """ | 
 |     app_path = os.path.abspath(app_path) | 
 |     if not os.path.exists(app_path): | 
 |       raise AppNotFoundError(app_path) | 
 |  | 
 |     if not find_xcode.find_xcode(xcode_version)['found']: | 
 |       raise XcodeVersionNotFoundError(xcode_version) | 
 |  | 
 |     if not os.path.exists(out_dir): | 
 |       os.makedirs(out_dir) | 
 |  | 
 |     self.app_name = os.path.splitext(os.path.split(app_path)[-1])[0] | 
 |     self.app_path = app_path | 
 |     self.cfbundleid = subprocess.check_output([ | 
 |         '/usr/libexec/PlistBuddy', | 
 |         '-c', 'Print:CFBundleIdentifier', | 
 |         os.path.join(app_path, 'Info.plist'), | 
 |     ]).rstrip() | 
 |     self.env_vars = env_vars or [] | 
 |     self.logs = collections.OrderedDict() | 
 |     self.out_dir = out_dir | 
 |     self.test_args = test_args or [] | 
 |     self.xcode_version = xcode_version | 
 |     self.xctest_path = '' | 
 |  | 
 |     self.test_results = {} | 
 |     self.test_results['version'] = 3 | 
 |     self.test_results['path_delimiter'] = '.' | 
 |     self.test_results['seconds_since_epoch'] = int(time.time()) | 
 |     # This will be overwritten when the tests complete successfully. | 
 |     self.test_results['interrupted'] = True | 
 |  | 
 |     if xctest: | 
 |       plugins_dir = os.path.join(self.app_path, 'PlugIns') | 
 |       if not os.path.exists(plugins_dir): | 
 |         raise PlugInsNotFoundError(plugins_dir) | 
 |       for plugin in os.listdir(plugins_dir): | 
 |         if plugin.endswith('.xctest'): | 
 |           self.xctest_path = os.path.join(plugins_dir, plugin) | 
 |       if not os.path.exists(self.xctest_path): | 
 |         raise XCTestPlugInNotFoundError(self.xctest_path) | 
 |  | 
 |   def get_launch_command(self, test_filter=None, invert=False): | 
 |     """Returns the command that can be used to launch the test app. | 
 |  | 
 |     Args: | 
 |       test_filter: List of test cases to filter. | 
 |       invert: Whether to invert the filter or not. Inverted, the filter will | 
 |         match everything except the given test cases. | 
 |  | 
 |     Returns: | 
 |       A list of strings forming the command to launch the test. | 
 |     """ | 
 |     raise NotImplementedError | 
 |  | 
 |   def get_launch_env(self): | 
 |     """Returns a dict of environment variables to use to launch the test app. | 
 |  | 
 |     Returns: | 
 |       A dict of environment variables. | 
 |     """ | 
 |     return os.environ.copy() | 
 |  | 
 |   def set_up(self): | 
 |     """Performs setup actions which must occur prior to every test launch.""" | 
 |     raise NotImplementedError | 
 |  | 
 |   def tear_down(self): | 
 |     """Performs cleanup actions which must occur after every test launch.""" | 
 |     raise NotImplementedError | 
 |  | 
 |   def screenshot_desktop(self): | 
 |     """Saves a screenshot of the desktop in the output directory.""" | 
 |     subprocess.check_call([ | 
 |         'screencapture', | 
 |         os.path.join(self.out_dir, 'desktop_%s.png' % time.time()), | 
 |     ]) | 
 |  | 
 |   def retrieve_derived_data(self): | 
 |     """Retrieves the contents of DerivedData""" | 
 |     # DerivedData contains some logs inside workspace-specific directories. | 
 |     # Since we don't control the name of the workspace or project, most of | 
 |     # the directories are just called "temporary", making it hard to tell | 
 |     # which directory we need to retrieve. Instead we just delete the | 
 |     # entire contents of this directory before starting and return the | 
 |     # entire contents after the test is over. | 
 |     if os.path.exists(DERIVED_DATA): | 
 |       os.mkdir(os.path.join(self.out_dir, 'DerivedData')) | 
 |       derived_data = os.path.join(self.out_dir, 'DerivedData') | 
 |       for directory in os.listdir(DERIVED_DATA): | 
 |         shutil.move(os.path.join(DERIVED_DATA, directory), derived_data) | 
 |  | 
 |   def wipe_derived_data(self): | 
 |     """Removes the contents of Xcode's DerivedData directory.""" | 
 |     if os.path.exists(DERIVED_DATA): | 
 |       shutil.rmtree(DERIVED_DATA) | 
 |       os.mkdir(DERIVED_DATA) | 
 |  | 
 |   def _run(self, cmd): | 
 |     """Runs the specified command, parsing GTest output. | 
 |  | 
 |     Args: | 
 |       cmd: List of strings forming the command to run. | 
 |  | 
 |     Returns: | 
 |       GTestResult instance. | 
 |     """ | 
 |     print ' '.join(cmd) | 
 |     print | 
 |  | 
 |     result = gtest_utils.GTestResult(cmd) | 
 |     if self.xctest_path: | 
 |       parser = xctest_utils.XCTestLogParser() | 
 |     else: | 
 |       parser = gtest_utils.GTestLogParser() | 
 |  | 
 |     proc = subprocess.Popen( | 
 |         cmd, | 
 |         env=self.get_launch_env(), | 
 |         stdout=subprocess.PIPE, | 
 |         stderr=subprocess.STDOUT, | 
 |     ) | 
 |  | 
 |     while True: | 
 |       line = proc.stdout.readline() | 
 |       if not line: | 
 |         break | 
 |       line = line.rstrip() | 
 |       parser.ProcessLine(line) | 
 |       print line | 
 |       sys.stdout.flush() | 
 |  | 
 |     proc.wait() | 
 |     sys.stdout.flush() | 
 |  | 
 |     for test in parser.FailedTests(include_flaky=True): | 
 |       # Test cases are named as <test group>.<test case>. If the test case | 
 |       # is prefixed with "FLAKY_", it should be reported as flaked not failed. | 
 |       if '.' in test and test.split('.', 1)[1].startswith('FLAKY_'): | 
 |         result.flaked_tests[test] = parser.FailureDescription(test) | 
 |       else: | 
 |         result.failed_tests[test] = parser.FailureDescription(test) | 
 |  | 
 |     result.passed_tests.extend(parser.PassedTests(include_flaky=True)) | 
 |  | 
 |     print '%s returned %s' % (cmd[0], proc.returncode) | 
 |     print | 
 |  | 
 |     # iossim can return 5 if it exits noncleanly even if all tests passed. | 
 |     # Therefore we cannot rely on process exit code to determine success. | 
 |     result.finalize(proc.returncode, parser.CompletedWithoutFailure()) | 
 |     return result | 
 |  | 
 |   def launch(self): | 
 |     """Launches the test app.""" | 
 |     self.set_up() | 
 |     cmd = self.get_launch_command() | 
 |     try: | 
 |       result = self._run(cmd) | 
 |       if result.crashed and not result.crashed_test: | 
 |         # If the app crashed but not during any particular test case, assume | 
 |         # it crashed on startup. Try one more time. | 
 |         print 'Crashed on startup, retrying...' | 
 |         print | 
 |         result = self._run(cmd) | 
 |  | 
 |       if result.crashed and not result.crashed_test: | 
 |         raise AppLaunchError | 
 |  | 
 |       passed = result.passed_tests | 
 |       failed = result.failed_tests | 
 |       flaked = result.flaked_tests | 
 |  | 
 |       try: | 
 |         # XCTests cannot currently be resumed at the next test case. | 
 |         while not self.xctest_path and result.crashed and result.crashed_test: | 
 |           # If the app crashes during a specific test case, then resume at the | 
 |           # next test case. This is achieved by filtering out every test case | 
 |           # which has already run. | 
 |           print 'Crashed during %s, resuming...' % result.crashed_test | 
 |           print | 
 |           result = self._run(self.get_launch_command( | 
 |               test_filter=passed + failed.keys() + flaked.keys(), invert=True, | 
 |           )) | 
 |           passed.extend(result.passed_tests) | 
 |           failed.update(result.failed_tests) | 
 |           flaked.update(result.flaked_tests) | 
 |       except OSError as e: | 
 |         if e.errno == errno.E2BIG: | 
 |           print 'Too many test cases to resume.' | 
 |           print | 
 |         else: | 
 |           raise | 
 |  | 
 |       # Build test_results.json. | 
 |       self.test_results['interrupted'] = result.crashed | 
 |       self.test_results['num_failures_by_type'] = { | 
 |         'FAIL': len(failed) + len(flaked), | 
 |         'PASS': len(passed), | 
 |       } | 
 |       tests = collections.OrderedDict() | 
 |       for test in passed: | 
 |         tests[test] = { 'expected': 'PASS', 'actual': 'PASS' } | 
 |       for test in failed: | 
 |         tests[test] = { 'expected': 'PASS', 'actual': 'FAIL' } | 
 |       for test in flaked: | 
 |         tests[test] = { 'expected': 'PASS', 'actual': 'FAIL' } | 
 |       self.test_results['tests'] = tests | 
 |  | 
 |       self.logs['passed tests'] = passed | 
 |       for test, log_lines in failed.iteritems(): | 
 |         self.logs[test] = log_lines | 
 |       for test, log_lines in flaked.iteritems(): | 
 |         self.logs[test] = log_lines | 
 |  | 
 |       return not failed | 
 |     finally: | 
 |       self.tear_down() | 
 |  | 
 |  | 
 | class SimulatorTestRunner(TestRunner): | 
 |   """Class for running tests on iossim.""" | 
 |  | 
 |   def __init__( | 
 |       self, | 
 |       app_path, | 
 |       iossim_path, | 
 |       platform, | 
 |       version, | 
 |       xcode_version, | 
 |       out_dir, | 
 |       env_vars=None, | 
 |       test_args=None, | 
 |       xctest=False, | 
 |   ): | 
 |     """Initializes a new instance of this class. | 
 |  | 
 |     Args: | 
 |       app_path: Path to the compiled .app or .ipa to run. | 
 |       iossim_path: Path to the compiled iossim binary to use. | 
 |       platform: Name of the platform to simulate. Supported values can be found | 
 |         by running "iossim -l". e.g. "iPhone 5s", "iPad Retina". | 
 |       version: Version of iOS the platform should be running. Supported values | 
 |         can be found by running "iossim -l". e.g. "9.3", "8.2", "7.1". | 
 |       xcode_version: Version of Xcode to use when running the test. | 
 |       out_dir: Directory to emit test data into. | 
 |       env_vars: List of environment variables to pass to the test itself. | 
 |       test_args: List of strings to pass as arguments to the test when | 
 |         launching. | 
 |       xctest: Whether or not this is an XCTest. | 
 |  | 
 |     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. | 
 |     """ | 
 |     super(SimulatorTestRunner, self).__init__( | 
 |         app_path, | 
 |         xcode_version, | 
 |         out_dir, | 
 |         env_vars=env_vars, | 
 |         test_args=test_args, | 
 |         xctest=xctest, | 
 |     ) | 
 |  | 
 |     iossim_path = os.path.abspath(iossim_path) | 
 |     if not os.path.exists(iossim_path): | 
 |       raise SimulatorNotFoundError(iossim_path) | 
 |  | 
 |     self.homedir = '' | 
 |     self.iossim_path = iossim_path | 
 |     self.platform = platform | 
 |     self.start_time = None | 
 |     self.version = version | 
 |  | 
 |   @staticmethod | 
 |   def kill_simulators(): | 
 |     """Kills all running simulators.""" | 
 |     try: | 
 |       subprocess.check_call([ | 
 |           'pkill', | 
 |           '-9', | 
 |           '-x', | 
 |           # The simulator's name varies by Xcode version. | 
 |           'iPhone Simulator', # Xcode 5 | 
 |           'iOS Simulator', # Xcode 6 | 
 |           'Simulator', # Xcode 7+ | 
 |           'simctl', # https://crbug.com/637429 | 
 |       ]) | 
 |       # If a signal was sent, wait for the simulators to actually be killed. | 
 |       time.sleep(5) | 
 |     except subprocess.CalledProcessError as e: | 
 |       if e.returncode != 1: | 
 |         # Ignore a 1 exit code (which means there were no simulators to kill). | 
 |         raise | 
 |  | 
 |   def wipe_simulator(self): | 
 |     """Wipes the simulator.""" | 
 |     subprocess.check_call([ | 
 |         self.iossim_path, | 
 |         '-d', self.platform, | 
 |         '-s', self.version, | 
 |         '-w', | 
 |     ]) | 
 |  | 
 |   def get_home_directory(self): | 
 |     """Returns the simulator's home directory.""" | 
 |     return subprocess.check_output([ | 
 |         self.iossim_path, | 
 |         '-d', self.platform, | 
 |         '-p', | 
 |         '-s', self.version, | 
 |     ]).rstrip() | 
 |  | 
 |   def set_up(self): | 
 |     """Performs setup actions which must occur prior to every test launch.""" | 
 |     self.kill_simulators() | 
 |     self.wipe_simulator() | 
 |     self.wipe_derived_data() | 
 |     self.homedir = self.get_home_directory() | 
 |     # Crash reports have a timestamp in their file name, formatted as | 
 |     # YYYY-MM-DD-HHMMSS. Save the current time in the same format so | 
 |     # we can compare and fetch crash reports from this run later on. | 
 |     self.start_time = time.strftime('%Y-%m-%d-%H%M%S', time.localtime()) | 
 |  | 
 |   def extract_test_data(self): | 
 |     """Extracts data emitted by the test.""" | 
 |     # Find the Documents directory of the test app. The app directory names | 
 |     # don't correspond with any known information, so we have to examine them | 
 |     # all until we find one with a matching CFBundleIdentifier. | 
 |     apps_dir = os.path.join( | 
 |         self.homedir, 'Containers', 'Data', 'Application') | 
 |     if os.path.exists(apps_dir): | 
 |       for appid_dir in os.listdir(apps_dir): | 
 |         docs_dir = os.path.join(apps_dir, appid_dir, 'Documents') | 
 |         metadata_plist = os.path.join( | 
 |             apps_dir, | 
 |             appid_dir, | 
 |             '.com.apple.mobile_container_manager.metadata.plist', | 
 |         ) | 
 |         if os.path.exists(docs_dir) and os.path.exists(metadata_plist): | 
 |           cfbundleid = subprocess.check_output([ | 
 |               '/usr/libexec/PlistBuddy', | 
 |               '-c', 'Print:MCMMetadataIdentifier', | 
 |               metadata_plist, | 
 |           ]).rstrip() | 
 |           if cfbundleid == self.cfbundleid: | 
 |             shutil.copytree(docs_dir, os.path.join(self.out_dir, 'Documents')) | 
 |             return | 
 |  | 
 |   def retrieve_crash_reports(self): | 
 |     """Retrieves crash reports produced by the test.""" | 
 |     # A crash report's naming scheme is [app]_[timestamp]_[hostname].crash. | 
 |     # e.g. net_unittests_2014-05-13-15-0900_vm1-a1.crash. | 
 |     crash_reports_dir = os.path.expanduser(os.path.join( | 
 |         '~', 'Library', 'Logs', 'DiagnosticReports')) | 
 |  | 
 |     if not os.path.exists(crash_reports_dir): | 
 |       return | 
 |  | 
 |     for crash_report in os.listdir(crash_reports_dir): | 
 |       report_name, ext = os.path.splitext(crash_report) | 
 |       if report_name.startswith(self.app_name) and ext == '.crash': | 
 |         report_time = report_name[len(self.app_name) + 1:].split('_')[0] | 
 |  | 
 |         # The timestamp format in a crash report is big-endian and therefore | 
 |         # a staight string comparison works. | 
 |         if report_time > self.start_time: | 
 |           with open(os.path.join(crash_reports_dir, crash_report)) as f: | 
 |             self.logs['crash report (%s)' % report_time] = ( | 
 |                 f.read().splitlines()) | 
 |  | 
 |   def tear_down(self): | 
 |     """Performs cleanup actions which must occur after every test launch.""" | 
 |     self.extract_test_data() | 
 |     self.retrieve_crash_reports() | 
 |     self.retrieve_derived_data() | 
 |     self.screenshot_desktop() | 
 |     self.kill_simulators() | 
 |     self.wipe_simulator() | 
 |     if os.path.exists(self.homedir): | 
 |       shutil.rmtree(self.homedir, ignore_errors=True) | 
 |       self.homedir = '' | 
 |  | 
 |   def get_launch_command(self, test_filter=None, invert=False): | 
 |     """Returns the command that can be used to launch the test app. | 
 |  | 
 |     Args: | 
 |       test_filter: List of test cases to filter. | 
 |       invert: Whether to invert the filter or not. Inverted, the filter will | 
 |         match everything except the given test cases. | 
 |  | 
 |     Returns: | 
 |       A list of strings forming the command to launch the test. | 
 |     """ | 
 |     cmd = [ | 
 |         self.iossim_path, | 
 |         '-d', self.platform, | 
 |         '-s', self.version, | 
 |     ] | 
 |  | 
 |     if test_filter: | 
 |       kif_filter = get_kif_test_filter(test_filter, invert=invert) | 
 |       gtest_filter = get_gtest_filter(test_filter, invert=invert) | 
 |       cmd.extend(['-e', 'GKIF_SCENARIO_FILTER=%s' % kif_filter]) | 
 |       cmd.extend(['-c', '--gtest_filter=%s' % gtest_filter]) | 
 |  | 
 |     for env_var in self.env_vars: | 
 |       cmd.extend(['-e', env_var]) | 
 |  | 
 |     for test_arg in self.test_args: | 
 |       cmd.extend(['-c', test_arg]) | 
 |  | 
 |     cmd.append(self.app_path) | 
 |     if self.xctest_path: | 
 |       cmd.append(self.xctest_path) | 
 |     return cmd | 
 |  | 
 |   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(SimulatorTestRunner, self).get_launch_env() | 
 |     if self.xctest_path: | 
 |       env['NSUnbufferedIO'] = 'YES' | 
 |     return env | 
 |  | 
 |  | 
 | class DeviceTestRunner(TestRunner): | 
 |   """Class for running tests on devices.""" | 
 |  | 
 |   def __init__( | 
 |     self, | 
 |     app_path, | 
 |     xcode_version, | 
 |     out_dir, | 
 |     env_vars=None, | 
 |     test_args=None, | 
 |     xctest=False, | 
 |   ): | 
 |     """Initializes a new instance of this class. | 
 |  | 
 |     Args: | 
 |       app_path: Path to the compiled .app to run. | 
 |       xcode_version: Version of Xcode to use when running the test. | 
 |       out_dir: Directory to emit test data into. | 
 |       env_vars: List of environment variables to pass to the test itself. | 
 |       test_args: List of strings to pass as arguments to the test when | 
 |         launching. | 
 |       xctest: Whether or not this is an XCTest. | 
 |  | 
 |     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. | 
 |     """ | 
 |     super(DeviceTestRunner, self).__init__( | 
 |       app_path, | 
 |       xcode_version, | 
 |       out_dir, | 
 |       env_vars=env_vars, | 
 |       test_args=test_args, | 
 |       xctest=xctest, | 
 |     ) | 
 |  | 
 |     self.udid = subprocess.check_output(['idevice_id', '--list']).rstrip() | 
 |     if len(self.udid.splitlines()) != 1: | 
 |       raise DeviceDetectionError(self.udid) | 
 |  | 
 |   def uninstall_apps(self): | 
 |     """Uninstalls all apps found on the device.""" | 
 |     for app in subprocess.check_output( | 
 |       ['idevicefs', '--udid', self.udid, 'ls', '@']).splitlines(): | 
 |       subprocess.check_call( | 
 |         ['ideviceinstaller', '--udid', self.udid, '--uninstall', app]) | 
 |  | 
 |   def install_app(self): | 
 |     """Installs the app.""" | 
 |     subprocess.check_call( | 
 |       ['ideviceinstaller', '--udid', self.udid, '--install', self.app_path]) | 
 |  | 
 |   def set_up(self): | 
 |     """Performs setup actions which must occur prior to every test launch.""" | 
 |     self.uninstall_apps() | 
 |     self.wipe_derived_data() | 
 |     self.install_app() | 
 |  | 
 |   def extract_test_data(self): | 
 |     """Extracts data emitted by the test.""" | 
 |     try: | 
 |       subprocess.check_call([ | 
 |         'idevicefs', | 
 |         '--udid', self.udid, | 
 |         'pull', | 
 |         '@%s/Documents' % self.cfbundleid, | 
 |         os.path.join(self.out_dir, 'Documents'), | 
 |       ]) | 
 |     except subprocess.CalledProcessError: | 
 |       raise TestDataExtractionError() | 
 |  | 
 |   def retrieve_crash_reports(self): | 
 |     """Retrieves crash reports produced by the test.""" | 
 |     logs_dir = os.path.join(self.out_dir, 'Logs') | 
 |     os.mkdir(logs_dir) | 
 |     try: | 
 |       subprocess.check_call([ | 
 |         'idevicecrashreport', | 
 |         '--extract', | 
 |         '--udid', self.udid, | 
 |         logs_dir, | 
 |       ]) | 
 |     except subprocess.CalledProcessError: | 
 |       raise TestDataExtractionError() | 
 |  | 
 |   def tear_down(self): | 
 |     """Performs cleanup actions which must occur after every test launch.""" | 
 |     self.screenshot_desktop() | 
 |     self.retrieve_derived_data() | 
 |     self.extract_test_data() | 
 |     self.retrieve_crash_reports() | 
 |     self.uninstall_apps() | 
 |  | 
 |   def get_launch_command(self, test_filter=None, invert=False): | 
 |     """Returns the command that can be used to launch the test app. | 
 |  | 
 |     Args: | 
 |       test_filter: List of test cases to filter. | 
 |       invert: Whether to invert the filter or not. Inverted, the filter will | 
 |         match everything except the given test cases. | 
 |  | 
 |     Returns: | 
 |       A list of strings forming the command to launch the test. | 
 |     """ | 
 |     if self.xctest_path: | 
 |       return [ | 
 |         'xcodebuild', | 
 |         'test-without-building', | 
 |         'BUILT_PRODUCTS_DIR=%s' % os.path.dirname(self.app_path), | 
 |         '-destination', 'id=%s' % self.udid, | 
 |         '-project', XCTEST_PROJECT, | 
 |         '-scheme', XCTEST_SCHEME, | 
 |       ] | 
 |  | 
 |     cmd = [ | 
 |       'idevice-app-runner', | 
 |       '--udid', self.udid, | 
 |       '--start', self.cfbundleid, | 
 |     ] | 
 |     args = [] | 
 |  | 
 |     if test_filter: | 
 |       kif_filter = get_kif_test_filter(test_filter, invert=invert) | 
 |       gtest_filter = get_gtest_filter(test_filter, invert=invert) | 
 |       cmd.extend(['-D', 'GKIF_SCENARIO_FILTER=%s' % kif_filter]) | 
 |       args.append('--gtest-filter=%s' % gtest_filter) | 
 |  | 
 |     for env_var in self.env_vars: | 
 |       cmd.extend(['-D', env_var]) | 
 |  | 
 |     if args or self.test_args: | 
 |       cmd.append('--args') | 
 |       cmd.extend(self.test_args) | 
 |       cmd.extend(args) | 
 |  | 
 |     return cmd | 
 |  | 
 |   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(DeviceTestRunner, self).get_launch_env() | 
 |     if self.xctest_path: | 
 |       env['NSUnbufferedIO'] = 'YES' | 
 |       # e.g. ios_web_shell_egtests | 
 |       env['APP_TARGET_NAME'] = os.path.splitext( | 
 |           os.path.basename(self.app_path))[0] | 
 |       # e.g. ios_web_shell_egtests_module | 
 |       env['TEST_TARGET_NAME'] = env['APP_TARGET_NAME'] + '_module' | 
 |     return env |