blob: 447928b554cbf399ba027aa2f32af7584f4f941d [file] [log] [blame]
# Copyright 2018 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 runner for running tests using xcodebuild."""
import collections
import logging
import os
import subprocess
import time
import file_util
import iossim_util
import test_apps
from test_result_util import ResultCollection, TestResult, TestStatus
import test_runner
import xcode_log_parser
LOGGER = logging.getLogger(__name__)
MAXIMUM_TESTS_PER_SHARD_FOR_RERUN = 20
XTDEVICE_FOLDER = os.path.expanduser('~/Library/Developer/XCTestDevices')
def _tests_decided_at_runtime(app_name):
"""Return if tests in app are selected at runtime by app_name.
This works for suites defined in chromium infra.
"""
suite_name_fragments = ['ios_chrome_multitasking_eg', '_flaky_eg']
return any(fragment in app_name for fragment in suite_name_fragments)
def erase_all_simulators(path=None):
"""Erases all simulator devices.
Args:
path: (str) A path with simulators
"""
command = ['xcrun', 'simctl']
if path:
command += ['--set', path]
LOGGER.info('Erasing all simulators from folder %s.' % path)
else:
LOGGER.info('Erasing all simulators.')
try:
subprocess.check_call(command + ['erase', 'all'])
except subprocess.CalledProcessError as e:
# Logging error instead of throwing so we don't cause failures in case
# this was indeed failing to clean up.
message = 'Failed to erase all simulators. Error: %s' % e.output
LOGGER.error(message)
def shutdown_all_simulators(path=None):
"""Shutdown all simulator devices.
Fix for DVTCoreSimulatorAdditionsErrorDomain error.
Args:
path: (str) A path with simulators
"""
command = ['xcrun', 'simctl']
if path:
command += ['--set', path]
LOGGER.info('Shutdown all simulators from folder %s.' % path)
else:
LOGGER.info('Shutdown all simulators.')
try:
subprocess.check_call(command + ['shutdown', 'all'])
except subprocess.CalledProcessError as e:
# Logging error instead of throwing so we don't cause failures in case
# this was indeed failing to clean up.
message = 'Failed to shutdown all simulators. Error: %s' % e.output
LOGGER.error(message)
def terminate_process(proc):
"""Terminates the process.
If an error occurs ignore it, just print out a message.
Args:
proc: A subprocess.
"""
try:
proc.terminate()
except OSError as ex:
LOGGER.error('Error while killing a process: %s' % ex)
class LaunchCommand(object):
"""Stores xcodebuild test launching command."""
def __init__(self,
egtests_app,
udid,
shards,
retries,
readline_timeout,
out_dir=os.path.basename(os.getcwd()),
use_clang_coverage=False,
env=None):
"""Initialize launch command.
Args:
egtests_app: (EgtestsApp) An egtests_app to run.
udid: (str) UDID of a device/simulator.
shards: (int) A number of shards.
readline_timeout: (int) Timeout to kill a test process when it doesn't
have output (in seconds).
retries: (int) A number of retries.
out_dir: (str) A folder in which xcodebuild will generate test output.
By default it is a current directory.
env: (dict) Environment variables.
Raises:
AppNotFoundError: At incorrect egtests_app parameter type.
"""
if not isinstance(egtests_app, test_apps.EgtestsApp):
raise test_runner.AppNotFoundError(
'Parameter `egtests_app` is not EgtestsApp: %s' % egtests_app)
self.egtests_app = egtests_app
self.udid = udid
self.shards = shards
self.readline_timeout = readline_timeout
self.retries = retries
self.out_dir = out_dir
self.use_clang_coverage = use_clang_coverage
self.env = env
self._log_parser = xcode_log_parser.get_parser()
def launch_attempt(self, cmd):
"""Launch a process and do logging simultaneously.
Args:
cmd: (list[str]) A command to run.
Returns:
output - command output as list of strings.
"""
proc = subprocess.Popen(
cmd,
env=self.env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
return test_runner.print_process_output(proc, timeout=self.readline_timeout)
def launch(self):
"""Launches tests using xcodebuild."""
overall_launch_command_result = ResultCollection()
shards = self.shards
running_tests = set(self.egtests_app.get_all_tests())
# total number of attempts is self.retries+1
for attempt in range(self.retries + 1):
# Erase all simulators per each attempt
if iossim_util.is_device_with_udid_simulator(self.udid):
# kill all running simulators to prevent possible memory leaks
test_runner.SimulatorTestRunner.kill_simulators()
shutdown_all_simulators()
shutdown_all_simulators(XTDEVICE_FOLDER)
erase_all_simulators()
erase_all_simulators(XTDEVICE_FOLDER)
outdir_attempt = os.path.join(self.out_dir, 'attempt_%d' % attempt)
cmd_list = self.egtests_app.command(outdir_attempt, 'id=%s' % self.udid,
shards)
# TODO(crbug.com/914878): add heartbeat logging to xcodebuild_runner.
LOGGER.info('Start test attempt #%d for command [%s]' % (
attempt, ' '.join(cmd_list)))
output = self.launch_attempt(cmd_list)
if hasattr(self, 'use_clang_coverage') and self.use_clang_coverage:
# out_dir of LaunchCommand object is the TestRunner out_dir joined with
# UDID. Use os.path.dirname to retrieve the TestRunner out_dir.
file_util.move_raw_coverage_data(self.udid,
os.path.dirname(self.out_dir))
result = self._log_parser.collect_test_results(outdir_attempt, output)
tests_selected_at_runtime = _tests_decided_at_runtime(
self.egtests_app.test_app_path)
# For most suites, only keep crash status from last attempt since retries
# will cover any missing tests. For these decided at runtime, retain
# crashes from all attempts and a dummy "crashed" result will be reported
# to indicate some tests might never ran.
# TODO(crbug.com/1235871): Switch back to excluded tests and set
# |overall_crash| to always True.
overall_launch_command_result.add_result_collection(
result, overwrite_crash=not tests_selected_at_runtime)
result.report_to_result_sink()
tests_to_include = set()
# |running_tests| are compiled tests in target intersecting with swarming
# sharding. For some suites, they are more than what's needed to run.
if not tests_selected_at_runtime:
tests_to_include = tests_to_include | (
running_tests - overall_launch_command_result.expected_tests())
# Add failed tests from last rounds for runtime decided suites and device
# suites.
tests_to_include = (
tests_to_include
| overall_launch_command_result.never_expected_tests())
self.egtests_app.included_tests = list(tests_to_include)
# Nothing to run in retry.
if not self.egtests_app.included_tests:
break
# If tests are not completed(interrupted or did not start) and there are
# >= 20 remaining tests, run them with the same number of shards.
# otherwise re-run with shards=1.
if (not result.crashed
# If need to re-run less than 20 tests, 1 shard should be enough.
or (len(running_tests) -
len(overall_launch_command_result.expected_tests()) <=
MAXIMUM_TESTS_PER_SHARD_FOR_RERUN)):
shards = 1
return overall_launch_command_result
class SimulatorParallelTestRunner(test_runner.SimulatorTestRunner):
"""Class for running simulator tests using xCode."""
def __init__(self, app_path, host_app_path, iossim_path, version, platform,
out_dir, **kwargs):
"""Initializes a new instance of SimulatorParallelTestRunner class.
Args:
app_path: (str) A path to egtests_app.
host_app_path: (str) A path to the host app for EG2.
iossim_path: Path to the compiled iossim binary to use.
Not used, but is required by the base class.
version: (str) iOS version to run simulator on.
platform: (str) Name of device.
out_dir: (str) A directory to emit test data into.
(Following are potential args in **kwargs)
release: (bool) Whether this test runner is running for a release build.
repeat_count: (int) Number of times to run each test (passed to test app).
retries: (int) A number to retry test run, will re-run only failed tests.
shards: (int) A number of shards. Default is 1.
test_cases: (list) List of tests to be included in the test run.
None or [] to include all tests.
test_args: List of strings to pass as arguments to the test when
launching.
use_clang_coverage: Whether code coverage is enabled in this run.
env_vars: List of environment variables to pass to the test itself.
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.
"""
kwargs['retries'] = kwargs.get('retries') or 1
super(SimulatorParallelTestRunner,
self).__init__(app_path, iossim_path, platform, version, out_dir,
**kwargs)
self.set_up()
self.host_app_path = None
if host_app_path != 'NO_PATH':
self.host_app_path = os.path.abspath(host_app_path)
self.logs = collections.OrderedDict()
self.release = kwargs.get('release') or False
self.test_results['path_delimiter'] = '/'
# Do not enable parallel testing when code coverage is enabled, because raw
# coverage data won't be produced with parallel testing.
if hasattr(self, 'use_clang_coverage') and self.use_clang_coverage:
self.shards = 1
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(test_runner.SimulatorTestRunner, self).get_launch_env()
env['NSUnbufferedIO'] = 'YES'
return env
def get_launch_test_app(self):
"""Returns the proper test_app for the run.
Returns:
An implementation of EgtestsApp for the runner.
"""
return test_apps.EgtestsApp(
self.app_path,
included_tests=self.test_cases,
env_vars=self.env_vars,
test_args=self.test_args,
release=self.release,
repeat_count=self.repeat_count,
host_app_path=self.host_app_path)
def launch(self):
"""Launches tests using xcodebuild."""
test_app = self.get_launch_test_app()
launch_command = LaunchCommand(
test_app,
udid=self.udid,
shards=self.shards,
readline_timeout=self.readline_timeout,
retries=self.retries,
out_dir=os.path.join(self.out_dir, self.udid),
use_clang_coverage=(hasattr(self, 'use_clang_coverage') and
self.use_clang_coverage),
env=self.get_launch_env())
overall_result = launch_command.launch()
# Deletes simulator used in the tests after tests end.
if iossim_util.is_device_with_udid_simulator(self.udid):
iossim_util.delete_simulator_by_udid(self.udid)
# Adds disabled tests to result.
overall_result.add_and_report_test_names_status(
launch_command.egtests_app.disabled_tests,
TestStatus.SKIP,
expected_status=TestStatus.SKIP,
test_log='Test disabled.')
# Adds unexpectedly skipped tests to result if applicable.
tests_selected_at_runtime = _tests_decided_at_runtime(self.app_path)
unexpectedly_skipped = []
# TODO(crbug.com/1048758): For the multitasking or any flaky test suites,
# |all_tests_to_run| contains more tests than what actually runs.
if not tests_selected_at_runtime:
# |all_tests_to_run| takes into consideration that only a subset of tests
# may have run due to the test sharding logic in run.py.
all_tests_to_run = set(launch_command.egtests_app.get_all_tests())
unexpectedly_skipped = list(all_tests_to_run -
overall_result.all_test_names())
overall_result.add_and_report_test_names_status(
unexpectedly_skipped,
TestStatus.SKIP,
test_log=('The test is compiled in test target but was unexpectedly '
'not run or not finished.'))
# Add a final crash status to result collection. It will be reported as
# part of step log in LUCI build.
if unexpectedly_skipped or overall_result.crashed:
overall_result.set_crashed_with_prefix(
crash_message_prefix_line=('Test application crash happened and may '
'result in missing tests:'))
self.test_results = overall_result.standard_json_output(path_delimiter='/')
self.logs.update(overall_result.test_runner_logs())
# Return False when:
# - There are unexpected tests (all results of the tests are unexpected), or
# - The overall status is crashed and tests are selected at runtime. (i.e.
# runner is unable to know if all scheduled tests appear in result.)
return (not overall_result.never_expected_tests() and
not (tests_selected_at_runtime and overall_result.crashed))
class DeviceXcodeTestRunner(SimulatorParallelTestRunner,
test_runner.DeviceTestRunner):
"""Class for running tests on real device using xCode."""
def __init__(self, app_path, host_app_path, out_dir, **kwargs):
"""Initializes a new instance of DeviceXcodeTestRunner class.
Args:
app_path: (str) A path to egtests_app.
host_app_path: (str) A path to the host app for EG2.
out_dir: (str) A directory to emit test data into.
(Following are potential args in **kwargs)
repeat_count: (int) Number of times to run each test (passed to test app).
retries: (int) A number to retry test run, will re-run only failed tests.
test_cases: (list) List of tests to be included in the test run.
None or [] to include all tests.
test_args: List of strings to pass as arguments to the test when
launching.
env_vars: List of environment variables to pass to the test itself.
Raises:
AppNotFoundError: If the given app does not exist.
DeviceDetectionError: If no device found.
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.
"""
test_runner.DeviceTestRunner.__init__(self, app_path, out_dir, **kwargs)
self.shards = 1 # For tests on real devices shards=1
self.version = None
self.platform = None
self.host_app_path = None
if host_app_path != 'NO_PATH':
self.host_app_path = os.path.abspath(host_app_path)
self.homedir = ''
self.release = kwargs.get('release') or False
self.set_up()
self.start_time = time.strftime('%Y-%m-%d-%H%M%S', time.localtime())
self.test_results['path_delimiter'] = '/'
def set_up(self):
"""Performs setup actions which must occur prior to every test launch."""
self.uninstall_apps()
self.wipe_derived_data()
def tear_down(self):
"""Performs cleanup actions which must occur after every test launch."""
test_runner.DeviceTestRunner.tear_down(self)
def launch(self):
try:
return super(DeviceXcodeTestRunner, self).launch()
finally:
self.tear_down()