forked from chromium/chromium
-
Notifications
You must be signed in to change notification settings - Fork 0
/
shard_util.py
232 lines (183 loc) · 7.83 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
# Copyright 2020 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 collections
import logging
import os
import re
import subprocess
import test_runner as tr
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 +(?:0[xX][0-9a-fA-F]+ )?-\[(?P<testSuite>[A-Za-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-Za-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]\w+ (?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'
]
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 tr.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: (string) 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.
test_counts = {}
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 = []
for test_class, class_output in zip(test_classes_output[0::2],
test_classes_output[1::2]):
if test_class in IGNORED_CLASSES:
continue
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: (string) response of 'otool -ov'
Returns:
(list) a list of (TestCase, testMethod), containing disabled tests.
"""
test_names = TEST_NAMES_DEBUG_APP_PATTERN.findall(stdout.decode('utf-8'))
test_names = list(
map(
lambda test_name: (test_name[0].encode('utf-8'), test_name[1].encode(
'utf-8')), 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)]
# 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
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