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