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