blob: b5486e96e5eb81595c51927b2c677a042483c461 [file] [log] [blame]
# Copyright 2015 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.
import copy
from recipe_engine import recipe_api
class iOSApi(recipe_api.RecipeApi):
# Mapping of common names of supported iOS devices to product types
# exposed by the Swarming server.
PRODUCT_TYPES = {
'iPad Air': 'iPad4,1',
'iPhone 5s': 'iPhone6,1',
'iPhone 6s': 'iPhone8,1',
}
def __init__(self, *args, **kwargs):
super(iOSApi, self).__init__(*args, **kwargs)
self.__config = None
def host_info(self):
"""Emits information about the current host and available tools."""
step_result = self.m.step('host and tools info', [
self.package_repo_resource(
'scripts', 'slave', 'ios', 'host_info.py'),
'--json-file', self.m.json.output(),
], infra_step=True, step_test_data=self.test_api.host_info)
if step_result.json.output:
step_result.presentation.step_text = '<br />OS X %s, Xcode %s (%s)' % (
step_result.json.output['Mac OS X Version'],
step_result.json.output['Xcode Version'],
step_result.json.output['Xcode Build Version'],
)
return step_result
def checkout(self, **kwargs):
"""Checks out Chromium."""
kwargs.setdefault('force', True)
self.m.gclient.set_config('ios')
checkout_dir = self.m.chromium_checkout.get_checkout_dir({})
if checkout_dir:
kwargs.setdefault('cwd', checkout_dir)
return self.m.bot_update.ensure_checkout(**kwargs)
@property
def configuration(self):
assert self.__config is not None
return self.__config['configuration']
@property
def using_gyp(self):
assert self.__config is not None
return not self.using_mb or self.__config.get('mb_type') == 'gyp'
@property
def using_mb(self):
assert self.__config is not None
return self.__config['mb_type'] is not None
@property
def platform(self):
assert self.__config is not None
if self.__config['sdk'].startswith('iphoneos'):
return 'device'
elif self.__config['sdk'].startswith('iphonesimulator'):
return 'simulator'
def read_build_config(
self,
master_name=None,
build_config_base_dir=None,
buildername=None,
):
"""Reads the iOS build config for this bot.
Args:
master_name: Name of a master to read the build config from, or None
to read from buildbot properties at run-time.
build_config_base_dir: Directory to search for build config master and
test include directories.
"""
buildername = buildername or self.m.properties['buildername']
master_name = master_name or self.m.properties['mastername']
build_config_base_dir = build_config_base_dir or (
self.m.path['checkout'].join('ios', 'build', 'bots'))
build_config_dir = build_config_base_dir.join(master_name)
include_dir = build_config_base_dir.join('tests')
self.__config = self.m.json.read(
'read build config',
build_config_dir.join('%s.json' % buildername),
step_test_data=lambda: self.m.json.test_api.output(
self._test_data['build_config']
),
).json.output
# If this bot is triggered by another bot, then the build configuration
# has to be read from the parent's build config. A triggered bot only
# specifies the tests.
parent = str(self.__config.get('triggered by', ''))
if parent:
parent_config = self.m.json.read(
'read parent build config (%s)' % parent,
build_config_dir.join('%s.json' % parent),
step_test_data=lambda: self.m.json.test_api.output(
self._test_data['parent_build_config'],
),
).json.output
for key in (
'xcode version',
'GYP_DEFINES',
'configuration',
'sdk',
'gn_args',
'gn_args_file',
'additional_compile_targets',
'mb_type',
):
if key in parent_config:
self.__config[key] = parent_config[key]
# In the older dict-based bot configs we didn't set these values
# since they were the same on every bot. In the newer configs they
# are set anyway since MB needs them as well.
if isinstance(self.__config['GYP_DEFINES'], dict):
self.__config['GYP_DEFINES']['component'] = 'static_library'
self.__config['GYP_DEFINES']['OS'] = 'ios'
# TODO(crbug.com/552146): Once 'all' works, the default should be ['all'].
self.__config.setdefault('additional_compile_targets', ['All'])
# In order to simplify the code that uses the values of self.__config, here
# we default to empty values of their respective types, so in other places
# we can iterate over them without having to check if they are in the dict
# at all.
self.__config.setdefault('triggered bots', {})
self.__config.setdefault('tests', [])
self.__config.setdefault('env', {})
self.__config.setdefault('mb_type', None)
self.__config.setdefault('gn_args', [])
self.__config.setdefault('use_analyze', True)
self.__config['mastername'] = master_name
# Elements of the "tests" list are dicts. There are two types of elements,
# determined by the presence of one of these mutually exclusive keys:
# "app": This says to run a particular app.
# "include": This says to include a common set of tests from include_dir.
# So now we go through the "tests" list replacing any "include" keys.
# The value of an "include" key is the name of a set of tests to include,
# which can be found as a .json file in include_dir. Read the contents
# lazily as needed into includes.
def read_include(includes):
"""Reads the contents of the given include.
Args:
include: Name of the include.
"""
return self.m.json.read(
'include %s' % include,
include_dir.join(include),
step_test_data=lambda: self.m.json.test_api.output({
'tests': [
{
'app': 'fake included test 1',
},
{
'app': 'fake included test 2',
},
],
}),
).json.output
includes = {}
expanded_tests_list = []
# expanded_tests_list will be the list of test dicts, with
# any "include" replaced with the tests from that include.
for element in self.__config['tests']:
if element.get('include'):
# This is an include dict.
include = str(element.pop('include'))
# Lazily read the include if we haven't already.
if include not in includes:
includes[include] = read_include(include)
# Now take each test dict from the include, update it with the
# extra keys (e.g. device, OS), and append to the list of tests.
for included_test in includes[include]['tests']:
expanded_tests_list.append(copy.deepcopy(included_test))
expanded_tests_list[-1].update(element)
else:
# This is a test dict.
expanded_tests_list.append(element)
self.__config['tests'] = expanded_tests_list
# Generate a unique ID we can use to refer to each test, since the config
# may specify to run the exact same test multiple times.
i = 0
for test in self.__config['tests']:
test['id'] = str(i)
i += 1
self.m.step('finalize build config', [
'echo',
'-e',
self.m.json.dumps(self.__config, indent=2),
])
cfg = self.m.chromium.make_config()
if self.using_gyp:
cfg.gyp_env.GYP_CROSSCOMPILE = 1
if isinstance(self.__config['GYP_DEFINES'], dict):
cfg.gyp_env.GYP_DEFINES = copy.deepcopy(self.__config['GYP_DEFINES'])
else:
cfg.gyp_env.GYP_DEFINES = dict(v.split('=') for
v in self.__config['GYP_DEFINES'])
self.m.chromium.c = cfg
use_goma = (cfg.gyp_env.GYP_DEFINES.get('use_goma') == '1' or
'use_goma=true' in self.__config['gn_args'])
if use_goma:
# Make sure these chromium configs are applied consistently for the
# rest of the recipe; they are needed in order for m.chromium.compile()
# to work correctly.
self.m.chromium.apply_config('ninja')
self.m.chromium.apply_config('default_compiler')
self.m.chromium.apply_config('goma')
# apply_config('goma') sets the old (wrong) directory for goma in
# chromium.c.compile_py.goma_dir, but calling ensure_goma() after
# that fixes things, and makes sure that goma is actually
# available as well.
self.m.chromium.ensure_goma()
return copy.deepcopy(self.__config)
def build(self, mb_config_path=None, gyp_script=None, suffix=None):
"""Builds from this bot's build config."""
assert self.__config is not None
suffix = ' (%s)' % suffix if suffix else ''
self.m.step(
'find xcode', [
self.package_repo_resource(
'scripts', 'slave', 'ios', 'find_xcode.py'),
'--json-file', self.m.json.output(),
'--version', self.__config['xcode version'],
], step_test_data=lambda: self.m.json.test_api.output({}))
if self.using_mb:
self.m.chromium.c.project_generator.tool = 'mb'
# Add the default GYP_DEFINES.
if isinstance(self.__config['GYP_DEFINES'], dict):
gyp_defines = [
'%s=%s' % (k, v) for k, v in self.__config['GYP_DEFINES'].iteritems()
]
else:
gyp_defines = self.__config['GYP_DEFINES']
mb_type = self.__config['mb_type']
gn_args = self.__config['gn_args']
env = {
'GYP_DEFINES': ' '.join(gyp_defines),
'LANDMINES_VERBOSE': '1',
}
# Add extra env variables.
env.update(self.__config['env'])
build_sub_path = ''
env['GYP_CROSSCOMPILE'] = '1'
env['GYP_GENERATORS'] = 'ninja'
build_sub_path = '%s-%s' % (self.configuration, {
'simulator': 'iphonesimulator',
'device': 'iphoneos',
}[self.platform])
cwd = self.m.path['checkout'].join('out', build_sub_path)
compile_targets = self.__config['additional_compile_targets']
cmd = ['ninja', '-C', cwd]
if self.using_mb:
# if we're using MB to generate build files, make sure we don't
# invoke GYP directly. We still want the GYP_DEFINES set in the
# environment, though, so that other hooks can key off of them.
env['GYP_CHROMIUM_NO_ACTION'] = '1'
step_result = self.m.gclient.runhooks(name='runhooks' + suffix, env=env)
step_result.presentation.step_text = (
'<br />GYP_DEFINES:<br />%s' % '<br />'.join(gyp_defines)
)
if self.using_mb:
step_result.presentation.step_text += '<br />GYP_CHROMIUM_NO_ACTION=1'
if self.using_mb:
self.m.chromium.run_mb(self.__config['mastername'],
self.m.properties['buildername'],
name='generate_build_files' + suffix,
mb_config_path=mb_config_path,
gyp_script=gyp_script,
build_dir='//out/' + build_sub_path)
use_analyze = self.__config['use_analyze']
if (use_analyze and
self.m.tryserver.is_tryserver and
'without patch' not in suffix):
affected_files = self.m.chromium_checkout.get_files_affected_by_patch(
cwd=self.m.path['checkout'])
# The same test may be configured to run on multiple simulators.
# Only specify each test once for the analyzer.
tests = list(set(test['app'] for test in self.__config['tests']))
test_targets, compile_targets = (
self.m.filter.analyze(
affected_files,
tests,
self.__config['additional_compile_targets'] + tests,
'trybot_analyze_config.json',
additional_names=['chromium', 'ios'],
mb_mastername=self.__config['mastername'],
)
)
test_targets = set(test_targets)
for test in self.__config['tests']:
if test['app'] not in test_targets:
test['skip'] = True
if compile_targets: # pragma: no cover
cmd.extend(compile_targets)
else:
return
use_goma = 'use_goma=1' in gyp_defines or 'use_goma=true' in gn_args
if use_goma:
self.m.chromium.compile(targets=compile_targets,
target=build_sub_path,
cwd=cwd)
else:
self.m.step('compile' + suffix, cmd, cwd=cwd)
def test(self, *args):
"""Runs tests as instructed by this bot's build config.
Args:
*args: Any additional arguments to pass to the test harness.
"""
assert self.__config is not None
test_failures = []
infrastructure_failures = []
for test in self.__config['tests']:
cmd = [
self.package_repo_resource(
'scripts', 'slave', 'ios', 'run.py'),
'--app', self.m.path['slave_build'].join(
self.most_recent_app_dir,
'%s.app' % test['app'],
),
'--json_file', self.m.json.output(),
]
if test.get('xctest'):
cmd.extend([
'--test-host', test['app'],
'--dummyproj', self.package_repo_resource(
'scripts', 'slave', 'ios', 'TestProject', 'TestProject.xcodeproj'),
])
step_name = test['app']
if self.platform == 'simulator':
cmd.extend([
'--iossim', self.m.path['slave_build'].join(self.most_recent_iossim),
'--platform', test['device type'],
'--version', test['os'],
])
# Since we may be running simulator tests on multiple platforms,
# include the platform and OS in the name of the step.
step_name = '%s (%s iOS %s)' % (
test['app'],
test['device type'],
test['os'],
)
cmd.extend(args)
if test.get('skip'): # pragma: no cover
step_result = self.m.step('[skipped] %s' % str(step_name), [])
step_result.presentation.step_text = (
'This test was skipped because it was not affected.'
)
continue
try:
step_result = self.m.step(
str(step_name),
cmd,
step_test_data=self.test_api.test_results,
)
except self.m.step.StepFailure as f:
step_result = f.result
# The test scripts use a return code of 2 to indicate
# an infrastructure failure.
if step_result.retcode == 2:
step_result.presentation.status = self.m.step.EXCEPTION
infrastructure_failures.append(step_name)
else:
test_failures.append(step_name)
if step_result.json.output:
step_result.presentation.logs.update(
step_result.json.output.get('logs', {})
)
step_result.presentation.links.update(
step_result.json.output.get('links', {})
)
step_result.presentation.step_text = (
step_result.json.output.get('step_text', '')
)
# Here we turn the build red if there were any test failures, or purple if
# there were any infrastructure failures. If there were both, turn the build
# red to call sheriff attention to the legitimate failures.
if test_failures:
raise self.m.step.StepFailure(
'Failed %s.' % ', '.join(test_failures + infrastructure_failures)
)
elif infrastructure_failures:
raise self.m.step.InfraFailure(
'Failed %s.' % ', '.join(infrastructure_failures)
)
def bootstrap_swarming(self):
"""Bootstraps Swarming."""
self.m.swarming.show_isolated_out_in_collect_step = False
self.m.swarming.show_shards_in_collect_step = True
self.m.swarming_client.checkout('stable')
self.m.swarming_client.query_script_version('swarming.py')
def isolate(self):
"""Isolates the tests specified in this bot's build config."""
assert self.__config
class Task(object):
def __init__(self, isolate_gen_file, step_name, test):
self.isolate_gen_file = isolate_gen_file
self.isolated_hash = None
self.step_name = step_name
self.task = None
self.test = copy.deepcopy(test)
self.tmp_dir = None
tasks = []
failures = []
skipped = []
cmd = [
'src/ios/build/bots/scripts/run.py',
'--app', '<(app_path)',
'--out-dir', '${ISOLATED_OUTDIR}',
'--xcode-version', '<(xcode_version)',
]
files = [
# .apps are directories. Need the trailing slash to isolate the
# contents of a directory.
'<(app_path)/',
'src/ios/build/bots/scripts/',
]
if self.platform == 'simulator':
iossim = self.m.path.join(self.most_recent_iossim)
cmd.extend([
'--iossim', iossim,
'--platform', '<(platform)',
'--version', '<(version)',
])
files.append(iossim)
isolate_template_contents = {
'conditions': [
['OS == "ios"', {
'variables': {
'command': cmd,
'files': files,
},
}],
],
}
if self.platform == 'simulator':
isolate_template_contents['conditions'][0][1]
isolate_template_contents = self.m.json.dumps(
isolate_template_contents, indent=2)
isolate_template = self.m.path['slave_build'].join('template.isolate')
step_result = self.m.file.write(
'generate template.isolate',
isolate_template,
isolate_template_contents,
)
step_result.presentation.logs['template.isolate'] = (
isolate_template_contents.splitlines())
tmp_dir = self.m.path.mkdtemp('isolate')
for test in self.__config['tests']:
step_name = str('%s (%s iOS %s)' % (
test['app'], test['device type'], test['os']))
if test.get('skip'):
skipped.append(step_name)
continue
app_path = self.m.path.join(
self.most_recent_app_dir,
'%s.app' % test['app'],
)
isolate_gen_file = tmp_dir.join('%s.isolate.gen.json' % test['id'])
try:
args = [
'--config-variable', 'OS', 'ios',
'--config-variable', 'app_path', app_path,
'--config-variable', 'xcode_version', test.get(
'xcode version', self.__config['xcode version']),
'--isolate', isolate_template,
'--isolated', tmp_dir.join('%s.isolated' % test['id']),
'--path-variable', 'app_path', app_path,
]
if self.platform == 'simulator':
args.extend([
'--config-variable', 'platform', test['device type'],
'--config-variable', 'version', test['os'],
])
isolate_gen_file_contents = self.m.json.dumps({
'args': args,
'dir': self.m.path['slave_build'],
'version': 1,
}, indent=2)
step_result = self.m.file.write(
'generate %s.isolate.gen.json' % test['id'],
isolate_gen_file,
isolate_gen_file_contents,
)
step_result.presentation.logs['%s.isolate.gen.json' % test['id']] = (
isolate_gen_file_contents.splitlines())
step_result.presentation.step_text = step_name
tasks.append(Task(isolate_gen_file, step_name, test))
except self.m.step.StepFailure as f:
f.result.presentation.status = self.m.step.EXCEPTION
failures.append(step_name)
if not tasks:
return tasks, failures, skipped
cmd = [
self.m.swarming_client.path.join('isolate.py'),
'batcharchive',
'--dump-json', self.m.json.output(),
'--isolate-server', self.m.isolate.isolate_server,
]
for task in tasks:
cmd.append(task.isolate_gen_file)
step_result = self.m.step(
'archive',
cmd,
infra_step=True,
step_test_data=lambda: self.m.json.test_api.output({
task.test['id']: 'fake-hash-%s' % task.test['id']
for task in tasks
}),
)
for task in tasks:
if task.test['id'] in step_result.json.output:
task.isolated_hash = step_result.json.output[task.test['id']]
return tasks, failures, skipped
def trigger(self, tasks):
"""Triggers the given Swarming tasks."""
failures = []
for task in tasks:
if not task.isolated_hash: # pragma: no cover
continue
task.tmp_dir = self.m.path.mkdtemp(task.test['id'])
swarming_task = self.m.swarming.task(
task.step_name, task.isolated_hash, task_output_dir=task.tmp_dir)
swarming_task.dimensions = {
'pool': 'Chrome',
'xcode_version': task.test.get(
'xcode version', self.__config['xcode version'])
}
if self.platform == 'simulator':
swarming_task.dimensions['os'] = 'Mac'
elif self.platform == 'device':
swarming_task.dimensions['os'] = 'iOS-%s' % str(task.test['os'])
swarming_task.dimensions['device_status'] = 'available'
swarming_task.dimensions['device'] = self.PRODUCT_TYPES.get(
task.test['device type'])
if not swarming_task.dimensions['device']:
failures.append(task.step_name)
# Create a dummy step so we can annotate it to explain what
# went wrong.
step_result = self.m.step('[trigger] %s' % task.step_name, [])
step_result.presentation.status = self.m.step.EXCEPTION
step_result.presentation.logs['supported devices'] = sorted(
self.PRODUCT_TYPES.keys())
step_result.presentation.step_text = (
'Requested unsupported device type.')
continue
swarming_task.tags.add('device_type:%s' % str(task.test['device type']))
swarming_task.tags.add('ios_version:%s' % str(task.test['os']))
swarming_task.tags.add('platform:%s' % self.platform)
swarming_task.tags.add('test:%s' % str(task.test['app']))
try:
self.m.swarming.trigger_task(swarming_task)
task.task = swarming_task
except self.m.step.StepFailure as f:
f.result.presentation.status = self.m.step.EXCEPTION
failures.append(task.step_name)
return failures
def test_swarming(self):
"""Runs tests on Swarming as instructed by this bot's build config."""
assert self.__config
test_failures = []
infra_failures = []
with self.m.step.nest('bootstrap swarming'):
self.bootstrap_swarming()
with self.m.step.nest('isolate'):
tasks, failures, skipped = self.isolate()
infra_failures.extend(failures)
if skipped:
with self.m.step.nest('skipped'):
for step_name in skipped:
# Create a dummy step to indicate we skipped this test.
step_result = self.m.step('[skipped] %s' % step_name, [])
step_result.presentation.step_text = (
'This test was skipped because it was not affected.'
)
with self.m.step.nest('trigger'):
failures = self.trigger(tasks)
infra_failures.extend(failures)
for task in tasks:
if not task.task:
# We failed to isolate or trigger this test.
# Create a dummy step for it and mark it as failed.
step_result = self.m.step(task.step_name, [])
step_result.presentation.status = self.m.step.EXCEPTION
step_result.presentation.step_text = 'Failed to trigger the test.'
infra_failures.append(task.step_name)
continue
try:
step_result = self.m.swarming.collect_task(task.task)
except self.m.step.StepFailure as f:
step_result = f.result
# We only run one shard, so the results we're interested in will
# always be shard 0.
swarming_summary = step_result.json.output['shards'][0]
state = swarming_summary['state']
exit_code = (swarming_summary.get('exit_codes') or [None])[0]
# Interpret the result and set the display appropriately.
if state == self.m.swarming.State.COMPLETED and exit_code is not None:
# Task completed and we got an exit code from the iOS test runner.
if exit_code == 1:
step_result.presentation.status = self.m.step.FAILURE
test_failures.append(task.step_name)
elif exit_code == 2:
# The iOS test runner exits 2 to indicate an infrastructure failure.
step_result.presentation.status = self.m.step.EXCEPTION
infra_failures.append(task.step_name)
elif state == self.m.swarming.State.TIMED_OUT:
# The task was killed for taking too long. This is a test failure
# because the test itself hung.
step_result.presentation.status = self.m.step.FAILURE
step_result.presentation.step_text = 'Test timed out.'
test_failures.append(task.step_name)
elif state == self.m.swarming.State.EXPIRED:
# No Swarming bot accepted the task in time.
step_result.presentation.status = self.m.step.EXCEPTION
step_result.presentation.step_text = (
'No suitable Swarming bot found in time.'
)
infra_failures.append(task.step_name)
else:
step_result.presentation.status = self.m.step.EXCEPTION
step_result.presentation.step_text = (
'Unexpected infrastructure failure.'
)
infra_failures.append(task.step_name)
# Add any iOS test runner results to the display.
test_summary = self.m.path.join(
task.task.task_output_dir, '0', 'summary.json')
if self.m.path.exists(test_summary): # pragma: no cover
with open(test_summary) as f:
test_summary_json = self.m.json.loads(f.read())
step_result.presentation.logs['test_summary.json'] = self.m.json.dumps(
test_summary_json, indent=2).splitlines()
step_result.presentation.logs.update(test_summary_json.get('logs', {}))
step_result.presentation.links.update(
test_summary_json.get('links', {}))
if test_summary_json.get('step_text'):
step_result.presentation.step_text = '%s<br />%s' % (
step_result.presentation.step_text, test_summary_json['step_text'])
if test_failures:
raise self.m.step.StepFailure(
'Failed %s.' % ', '.join(sorted(set(test_failures + infra_failures))))
elif infra_failures:
raise self.m.step.InfraFailure(
'Failed %s.' % ', '.join(sorted(set(infra_failures))))
@property
def most_recent_app_dir(self):
"""Returns the path to the directory of the most recently compiled apps."""
platform = {
'device': 'iphoneos',
'simulator': 'iphonesimulator',
}[self.platform]
return self.m.path.join(
'src',
'out',
'%s-%s' % (self.configuration, platform),
)
@property
def most_recent_iossim(self):
"""Returns the path to the most recently compiled iossim."""
platform = {
'device': 'iphoneos',
'simulator': 'iphonesimulator',
}[self.platform]
return self.m.path.join(
'src', 'out', '%s-%s' % (self.configuration, platform), 'iossim')