forked from chromium/chromium
-
Notifications
You must be signed in to change notification settings - Fork 1
/
shard_util.py
294 lines (233 loc) · 10.4 KB
/
shard_util.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
# Copyright 2020 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import collections
import logging
import os
import re
import subprocess
import sys
import test_runner_errors
LOGGER = logging.getLogger(__name__)
# WARNING: THESE DUPLICATE CONSTANTS IN:
# //build/scripts/slave/recipe_modules/ios/api.py
# Regex to parse all compiled EG tests, including disabled (prepended with
# DISABLED_ or FLAKY_).
TEST_NAMES_DEBUG_APP_PATTERN = re.compile(
'imp .*-\[(?P<testSuite>[A-Z][A-Za-z0-9_]'
'*Test[Case]*) (?P<testMethod>(?:DISABLED_|FLAKY_)?test[A-Za-z0-9_]*)\]')
TEST_CLASS_RELEASE_APP_PATTERN = re.compile(
r'name +0[xX]\w+ '
'(?P<testSuite>[A-Z][A-Za-z0-9_]*Test(?:Case|))\n')
# Regex to parse all compiled EG tests, including disabled (prepended with
# DISABLED_ or FLAKY_).
TEST_NAME_RELEASE_APP_PATTERN = re.compile(
r'name +0[xX].+ (?P<testCase>(?:DISABLED_|FLAKY_)?test[A-Za-z0-9_]+)\n')
# 'ChromeTestCase' and 'BaseEarlGreyTestCase' are parent classes
# of all EarlGrey/EarlGrey2 test classes. 'appConfigurationForTestCase' is a
# class method. They have no real tests.
IGNORED_CLASSES = [
'BaseEarlGreyTestCase', 'ChromeTestCase', 'appConfigurationForTestCase',
'setUpForTestCase', 'GREYTest'
]
# Test class can inherit from another class so the parent class's test methods
# will automatically be run in the child class. However, the existing otool
# parsing logic does not take into account of inheritance.
# The below dictionary has subclass as the key and superclass as value.
INHERITANCE_CLASS_DICT = {
'PasswordManagerPasswordCheckupDisabledTestCase': 'PasswordManagerTestCase'
}
class OtoolError(test_runner_errors.Error):
"""OTool non-zero error code"""
def __init__(self, code):
super(OtoolError,
self).__init__('otool returned a non-zero return code: %s' % code)
class ShardingError(test_runner_errors.Error):
"""Error related with sharding logic."""
pass
def shard_index():
"""Returns shard index in environment, or 0 if not in sharding environment."""
return int(os.getenv('GTEST_SHARD_INDEX', 0))
def total_shards():
"""Returns total shard count in environment, or 1 if not in environment."""
return int(os.getenv('GTEST_TOTAL_SHARDS', 1))
def determine_app_path(app, host_app=None, release=False):
"""String manipulate args.app and args.host to determine what path to use
for otools
Args:
app: (string) args.app
host_app: (string) args.host_app
release: (bool) whether it's a release app
Returns:
(string) path to app for otools to analyze
"""
# run.py invoked via ../../ios/build/bots/scripts/, so we reverse this
dirname = os.path.dirname(os.path.abspath(__file__))
build_type = "Release" if release else "Debug"
# location of app: /b/s/w/ir/out/{build_type}/test.app
full_app_path = os.path.normpath(
os.path.join(dirname, '../../../..', 'out', build_type, app))
# ie/ if app_path = "../../some.app", app_name = some
app_name = os.path.basename(app)
app_name = app_name[:app_name.rindex('.app')]
# Default app_path looks like /b/s/w/ir/out/{build_type}/test.app/test
app_path = os.path.join(full_app_path, app_name)
if host_app and host_app != 'NO_PATH':
LOGGER.debug("Detected EG2 test while building application path. "
"Host app: {}".format(host_app))
# EG2 tests always end in -Runner, so we split that off
app_name = app_name[:app_name.rindex('-Runner')]
app_path = os.path.join(full_app_path, 'PlugIns',
'{}.xctest'.format(app_name), app_name)
return app_path
def _execute(cmd):
"""Helper for executing a command."""
LOGGER.info('otool command: {}'.format(cmd))
process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
stdout = process.communicate()[0]
retcode = process.returncode
LOGGER.info('otool return status code: {}'.format(retcode))
if retcode:
raise OtoolError(retcode)
return stdout
def fetch_test_names_for_release(stdout):
"""Parse otool output to get all testMethods in all TestCases in the
format of (TestCase, testMethod) including disabled tests, in release app.
WARNING: This logic is similar to what's found in
//build/scripts/slave/recipe_modules/ios/api.py
Args:
stdout: (bytes) response of 'otool -ov'
Returns:
(list) a list of (TestCase, testMethod), containing disabled tests.
"""
# For Release builds `otool -ov` command generates output that is
# different from Debug builds.
# Parsing implemented in such a way:
# 1. Parse test class names.
# 2. If they are not in ignored list, parse test method names.
# 3. Calculate test count per test class.
# |stdout| will be bytes on python3, and therefore must be decoded prior
# to running a regex.
if sys.version_info.major == 3:
stdout = stdout.decode('utf-8')
res = re.split(TEST_CLASS_RELEASE_APP_PATTERN, stdout)
# Ignore 1st element in split since it does not have any test class data
test_classes_output = res[1:]
test_names = []
output_size = len(test_classes_output)
# TEST_CLASS_RELEASE_APP_PATTERN appears twice for each TestCase. First time
# is for class definition followed by instance methods. Second time is for
# meta class definition followed by class methods. Lines in between the two
# contain testMethods for it. Thus, index 0, 4, 8... are test class names.
# Index 1, 5, 9... are outputs including corresponding test methods.
for group_index in range(output_size // 4):
class_index = group_index * 4
if (class_index + 2 >= output_size or test_classes_output[class_index] !=
test_classes_output[class_index + 2]):
raise ShardingError('Incorrect otool output in which a test class name '
'doesn\'t appear in group of 2. Test class: %s' %
test_classes_output[class_index])
test_class = test_classes_output[class_index]
if test_class in IGNORED_CLASSES:
continue
class_output = test_classes_output[class_index + 1]
methods = TEST_NAME_RELEASE_APP_PATTERN.findall(class_output)
test_names.extend((test_class, test_method) for test_method in methods)
return test_names
def fetch_test_names_for_debug(stdout):
"""Parse otool output to get all testMethods in all TestCases in the
format of (TestCase, testMethod) including disabled tests, in debug app.
Args:
stdout: (bytes) response of 'otool -ov'
Returns:
(list) a list of (TestCase, testMethod), containing disabled tests.
"""
# |stdout| will be bytes on python3, and therefore must be decoded prior
# to running a regex.
if sys.version_info.major == 3:
stdout = stdout.decode('utf-8')
test_names = TEST_NAMES_DEBUG_APP_PATTERN.findall(stdout)
test_names = list(
map(lambda test_name: (test_name[0], test_name[1]), test_names))
return list(
filter(lambda test_name: test_name[0] not in IGNORED_CLASSES, test_names))
def fetch_test_names(app, host_app, release, enabled_tests_only=True):
"""Determine the list of (TestCase, testMethod) for the app.
Args:
app: (string) path to app
host_app: (string) path to host app. None or "NO_PATH" for EG1.
release: (bool) whether this is a release build.
enabled_tests_only: (bool) output only enabled tests.
Returns:
(list) a list of (TestCase, testMethod).
"""
# Determine what path to use
app_path = determine_app_path(app, host_app, release)
# Use otools to get the test counts
cmd = ['otool', '-ov', app_path]
stdout = _execute(cmd)
LOGGER.info("Ignored test classes: {}".format(IGNORED_CLASSES))
if release:
LOGGER.info("Release build detected. Fetching test names for release.")
all_test_names = (
fetch_test_names_for_release(stdout)
if release else fetch_test_names_for_debug(stdout))
enabled_test_names = (
list(
filter(lambda test_name: test_name[1].startswith('test'),
all_test_names)))
return enabled_test_names if enabled_tests_only else all_test_names
def balance_into_sublists(test_counts, total_shards):
"""Augment the result of otool into balanced sublists
Args:
test_counts: (collections.Counter) dict of test_case to test case numbers
total_shards: (int) total number of shards this was divided into
Returns:
list of list of test classes
"""
class Shard(object):
"""Stores list of test classes and number of all tests"""
def __init__(self):
self.test_classes = []
self.size = 0
shards = [Shard() for i in range(total_shards)]
# TODO(crbug.com/1480192): we should implement a programmatic logic
# to detect inheritance instead of hardcoding them manually.
# It's very challenging to use regex to detect inheritance in otool,
# so it be might best to dig around xcodebuild -enumerate-tests.
for subclass, superclass in INHERITANCE_CLASS_DICT.items():
if superclass in test_counts and subclass in test_counts:
test_counts[subclass] += test_counts[superclass]
# Balances test classes between shards to have
# approximately equal number of tests per shard.
for test_class, number_of_test_methods in test_counts.most_common():
min_shard = min(shards, key=lambda shard: shard.size)
min_shard.test_classes.append(test_class)
min_shard.size += number_of_test_methods
LOGGER.debug('%s test case is allocated to shard %s with %s test methods' %
(test_class, shards.index(min_shard), number_of_test_methods))
sublists = [shard.test_classes for shard in shards]
return sublists
def shard_test_cases(args, shard_index, total_shards):
"""Shard test cases into total_shards, and determine which test cases to
run for this shard.
Args:
args: all parsed arguments passed to run.py
shard_index: the shard index(number) for this run
total_shards: the total number of shards for this test
Returns: a list of test cases to execute
"""
# Convert to dict format
dict_args = vars(args)
app = dict_args['app']
host_app = dict_args.get('host_app', None)
release = dict_args.get('release', False)
test_counts = collections.Counter(
test_class for test_class, _ in fetch_test_names(app, host_app, release))
# Ensure shard and total shard is int
shard_index = int(shard_index)
total_shards = int(total_shards)
sublists = balance_into_sublists(test_counts, total_shards)
tests = sublists[shard_index]
LOGGER.info("Tests to be executed this round: {}".format(tests))
return tests