Source code

Revision control

Other Tools

1
# This Source Code Form is subject to the terms of the Mozilla Public
2
# License, v. 2.0. If a copy of the MPL was not distributed with this
3
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5
from __future__ import absolute_import, print_function, unicode_literals
6
7
import collections
8
import json
9
import hashlib
10
import os
11
import shutil
12
import six
13
import sqlite3
14
import subprocess
15
import requests
16
import datetime
17
18
19
from mozboot.util import get_state_dir
20
from mozbuild.base import MozbuildObject
21
from mozpack.files import FileFinder
22
from moztest.resolve import TestResolver
23
from mozversioncontrol import get_repository_object
24
25
from ..cli import BaseTryParser
26
from ..tasks import generate_tasks, filter_tasks_by_paths, resolve_tests_by_suite
27
from ..push import push_to_try, generate_try_task_config
28
29
here = os.path.abspath(os.path.dirname(__file__))
30
build = MozbuildObject.from_environment(cwd=here)
31
vcs = get_repository_object(build.topsrcdir)
32
33
root_hash = hashlib.sha256(os.path.abspath(build.topsrcdir)).hexdigest()
34
cache_dir = os.path.join(get_state_dir(), 'cache', root_hash, 'chunk_mapping')
35
if not os.path.isdir(cache_dir):
36
os.makedirs(cache_dir)
37
CHUNK_MAPPING_FILE = os.path.join(cache_dir, 'chunk_mapping.sqlite')
38
CHUNK_MAPPING_TAG_FILE = os.path.join(cache_dir, 'chunk_mapping_tag.json')
39
40
# Maps from platform names in the chunk_mapping sqlite database to respective
41
# substrings in task names.
42
PLATFORM_MAP = {
43
'linux': 'test-linux64/opt',
44
'windows': 'test-windows10-64/opt',
45
}
46
47
# List of platform/build type combinations that are included in pushes by |mach try coverage|.
48
OPT_TASK_PATTERNS = [
49
'macosx64/opt',
50
'windows10-64/opt',
51
'windows7-32/opt',
52
'linux64/opt',
53
]
54
55
56
class CoverageParser(BaseTryParser):
57
name = 'coverage'
58
arguments = []
59
common_groups = ['push', 'task']
60
task_configs = [
61
"artifact",
62
"env",
63
"rebuild",
64
"chemspill-prio",
65
"disable-pgo",
66
"worker-overrides",
67
]
68
69
70
def read_test_manifests():
71
'''Uses TestResolver to read all test manifests in the tree.
72
73
Returns a (tests, support_files_map) tuple that describes the tests in the tree:
74
tests - a set of test file paths
75
support_files_map - a dict that maps from each support file to a list with
76
test files that require them it
77
'''
78
test_resolver = TestResolver.from_environment(cwd=here)
79
file_finder = FileFinder(build.topsrcdir)
80
support_files_map = collections.defaultdict(list)
81
tests = set()
82
83
for test in test_resolver.resolve_tests(build.topsrcdir):
84
tests.add(test['srcdir_relpath'])
85
if 'support-files' not in test:
86
continue
87
88
for support_file_pattern in test['support-files'].split():
89
# Get the pattern relative to topsrcdir.
90
if support_file_pattern.startswith('!/'):
91
support_file_pattern = support_file_pattern[2:]
92
elif support_file_pattern.startswith('/'):
93
support_file_pattern = support_file_pattern[1:]
94
else:
95
support_file_pattern = os.path.normpath(os.path.join(test['dir_relpath'],
96
support_file_pattern))
97
98
# If it doesn't have a glob, then it's a single file.
99
if '*' not in support_file_pattern:
100
# Simple case: single support file, just add it here.
101
support_files_map[support_file_pattern].append(test['srcdir_relpath'])
102
continue
103
104
for support_file, _ in file_finder.find(support_file_pattern):
105
support_files_map[support_file].append(test['srcdir_relpath'])
106
107
return tests, support_files_map
108
109
110
# TODO cache the output of this function
111
all_tests, all_support_files = read_test_manifests()
112
113
114
def download_coverage_mapping(base_revision):
115
try:
116
with open(CHUNK_MAPPING_TAG_FILE, 'r') as f:
117
tags = json.load(f)
118
if tags['target_revision'] == base_revision:
119
return
120
else:
121
print('Base revision changed.')
122
except (IOError, ValueError):
123
print('Chunk mapping file not found.')
124
127
128
# Get pushes from at most one month ago.
129
PUSH_HISTORY_DAYS = 30
130
delta = datetime.timedelta(days=PUSH_HISTORY_DAYS)
131
start_time = (datetime.datetime.now() - delta).strftime('%Y-%m-%d')
132
pushes_url = JSON_PUSHES_URL_TEMPLATE.format(start_time)
133
pushes_data = requests.get(pushes_url + '&tochange={}'.format(base_revision)).json()
134
if 'error' in pushes_data:
135
if 'unknown revision' in pushes_data['error']:
136
print('unknown revision {}, trying with latest mozilla-central'.format(base_revision))
137
pushes_data = requests.get(pushes_url).json()
138
139
if 'error' in pushes_data:
140
raise Exception(pushes_data['error'])
141
142
pushes = pushes_data['pushes']
143
144
print('Looking for coverage data. This might take a minute or two.')
145
print('Base revision:', base_revision)
146
for push_id in sorted(pushes.keys())[::-1]:
147
rev = pushes[push_id]['changesets'][0]
148
url = CHUNK_MAPPING_URL_TEMPLATE.format(rev)
149
print('push id: {},\trevision: {}'.format(push_id, rev))
150
151
r = requests.head(url)
152
if not r.ok:
153
continue
154
155
print('Chunk mapping found, downloading...')
156
r = requests.get(url, stream=True)
157
158
CHUNK_MAPPING_ARCHIVE = os.path.join(build.topsrcdir, 'chunk_mapping.tar.xz')
159
with open(CHUNK_MAPPING_ARCHIVE, 'wb') as f:
160
r.raw.decode_content = True
161
shutil.copyfileobj(r.raw, f)
162
163
subprocess.check_call(['tar', '-xJf', CHUNK_MAPPING_ARCHIVE,
164
'-C', os.path.dirname(CHUNK_MAPPING_FILE)])
165
os.remove(CHUNK_MAPPING_ARCHIVE)
166
assert os.path.isfile(CHUNK_MAPPING_FILE)
167
with open(CHUNK_MAPPING_TAG_FILE, 'w') as f:
168
json.dump({'target_revision': base_revision,
169
'chunk_mapping_revision': rev,
170
'download_date': start_time},
171
f)
172
return
173
raise Exception('Could not find suitable coverage data.')
174
175
176
def is_a_test(cursor, path):
177
'''Checks the all_tests global and the chunk mapping database to see if a
178
given file is a test file.
179
'''
180
if path in all_tests:
181
return True
182
183
cursor.execute('SELECT COUNT(*) from chunk_to_test WHERE path=?', (path,))
184
if cursor.fetchone()[0]:
185
return True
186
187
cursor.execute('SELECT COUNT(*) from file_to_test WHERE test=?', (path,))
188
if cursor.fetchone()[0]:
189
return True
190
191
return False
192
193
194
def tests_covering_file(cursor, path):
195
'''Returns a set of tests that cover a given source file.
196
'''
197
cursor.execute('SELECT test FROM file_to_test WHERE source=?', (path,))
198
return set(e[0] for e in cursor.fetchall())
199
200
201
def tests_in_chunk(cursor, platform, chunk):
202
'''Returns a set of tests that are contained in a given chunk.
203
'''
204
cursor.execute('SELECT path FROM chunk_to_test WHERE platform=? AND chunk=?',
205
(platform, chunk))
206
# Because of bug 1480103, some entries in this table contain both a file name and a test name,
207
# separated by a space. With the split, only the file name is kept.
208
return set(e[0].split(' ')[0] for e in cursor.fetchall())
209
210
211
def chunks_covering_file(cursor, path):
212
'''Returns a set of (platform, chunk) tuples with the chunks that cover a given source file.
213
'''
214
cursor.execute('SELECT platform, chunk FROM file_to_chunk WHERE path=?', (path,))
215
return set(cursor.fetchall())
216
217
218
def tests_supported_by_file(path):
219
'''Returns a set of tests that are using the given file as a support-file.
220
'''
221
return set(all_support_files[path])
222
223
224
def find_tests(changed_files):
225
'''Finds both individual tests and test chunks that should be run to test code changes.
226
Argument: a list of file paths relative to the source checkout.
227
228
Returns: a (test_files, test_chunks) tuple with two sets.
229
test_files - contains tests that should be run to verify changes to changed_files.
230
test_chunks - contains (platform, chunk) tuples with chunks that should be
231
run. These chunnks do not support running a subset of the tests (like
232
cppunit or gtest), so the whole chunk must be run.
233
'''
234
test_files = set()
235
test_chunks = set()
236
files_no_coverage = set()
237
238
with sqlite3.connect(CHUNK_MAPPING_FILE) as conn:
239
c = conn.cursor()
240
for path in changed_files:
241
# If path is a test, add it to the list and continue.
242
if is_a_test(c, path):
243
test_files.add(path)
244
continue
245
246
# Look at the chunk mapping and add all tests that cover this file.
247
tests = tests_covering_file(c, path)
248
chunks = chunks_covering_file(c, path)
249
# If we found tests covering this, then it's not a support-file, so
250
# save these and continue.
251
if tests or chunks:
252
test_files |= tests
253
test_chunks |= chunks
254
continue
255
256
# Check if the path is a support-file for any test, by querying test manifests.
257
tests = tests_supported_by_file(path)
258
if tests:
259
test_files |= tests
260
continue
261
262
# There is no coverage information for this file.
263
files_no_coverage.add(path)
264
265
files_covered = set(changed_files) - files_no_coverage
266
test_files = set(s.replace('\\', '/') for s in test_files)
267
268
_print_found_tests(files_covered, files_no_coverage, test_files, test_chunks)
269
270
remaining_test_chunks = set()
271
# For all test_chunks, try to find the tests contained by them in the
272
# chunk_to_test mapping.
273
for platform, chunk in test_chunks:
274
tests = tests_in_chunk(c, platform, chunk)
275
if tests:
276
for test in tests:
277
test_files.add(test.replace('\\', '/'))
278
else:
279
remaining_test_chunks.add((platform, chunk))
280
281
return test_files, remaining_test_chunks
282
283
284
def _print_found_tests(files_covered, files_no_coverage, test_files, test_chunks):
285
'''Print a summary of what will be run to the user's terminal.
286
'''
287
files_covered = sorted(files_covered)
288
files_no_coverage = sorted(files_no_coverage)
289
test_files = sorted(test_files)
290
test_chunks = sorted(test_chunks)
291
292
if files_covered:
293
print('Found {} modified source files with test coverage:'.format(len(files_covered)))
294
for covered in files_covered:
295
print('\t', covered)
296
297
if files_no_coverage:
298
print('Found {} modified source files with no coverage:'.format(len(files_no_coverage)))
299
for f in files_no_coverage:
300
print('\t', f)
301
302
if not files_covered:
303
print('No modified source files are covered by tests.')
304
elif not files_no_coverage:
305
print('All modified source files are covered by tests.')
306
307
if test_files:
308
print('Running {} individual test files.'.format(len(test_files)))
309
else:
310
print('Could not find any individual tests to run.')
311
312
if test_chunks:
313
print('Running {} test chunks.'.format(len(test_chunks)))
314
for platform, chunk in test_chunks:
315
print('\t', platform, chunk)
316
else:
317
print('Could not find any test chunks to run.')
318
319
320
def filter_tasks_by_chunks(tasks, chunks):
321
'''Find all tasks that will run the given chunks.
322
'''
323
selected_tasks = set()
324
for platform, chunk in chunks:
325
platform = PLATFORM_MAP[platform]
326
327
selected_task = None
328
for task in tasks:
329
if not task.startswith(platform):
330
continue
331
332
if not any(task[len(platform) + 1:].endswith(c) for c in [chunk, chunk + '-e10s']):
333
continue
334
335
assert selected_task is None, 'Only one task should be selected for a given platform-chunk couple ({} - {}), {} and {} were selected'.format(platform, chunk, selected_task, task) # noqa
336
selected_task = task
337
338
if selected_task is None:
339
print('Warning: no task found for chunk', platform, chunk)
340
else:
341
selected_tasks.add(selected_task)
342
343
return list(selected_tasks)
344
345
346
def is_opt_task(task):
347
'''True if the task runs on a supported platform and build type combination.
348
This is used to remove -ccov/asan/pgo tasks, along with all /debug tasks.
349
'''
350
return any(platform in task for platform in OPT_TASK_PATTERNS)
351
352
353
def run(try_config={}, full=False, parameters=None, push=True, message='{msg}', closed_tree=False):
354
download_coverage_mapping(vcs.base_ref)
355
356
changed_sources = vcs.get_outgoing_files()
357
test_files, test_chunks = find_tests(changed_sources)
358
if not test_files and not test_chunks:
359
print('ERROR Could not find any tests or chunks to run.')
360
return 1
361
362
tg = generate_tasks(parameters, full)
363
all_tasks = tg.tasks.keys()
364
365
tasks_by_chunks = filter_tasks_by_chunks(all_tasks, test_chunks)
366
tasks_by_path = filter_tasks_by_paths(all_tasks, test_files)
367
tasks = filter(is_opt_task, set(tasks_by_path + tasks_by_chunks))
368
369
if not tasks:
370
print('ERROR Did not find any matching tasks after filtering.')
371
return 1
372
test_count_message = ('{test_count} test file{test_plural} that ' +
373
'cover{test_singular} these changes ' +
374
'({task_count} task{task_plural} to be scheduled)').format(
375
test_count=len(test_files),
376
test_plural='' if len(test_files) == 1 else 's',
377
test_singular='s' if len(test_files) == 1 else '',
378
task_count=len(tasks),
379
task_plural='' if len(tasks) == 1 else 's')
380
print('Found ' + test_count_message)
381
382
# Set the test paths to be run by setting MOZHARNESS_TEST_PATHS.
383
path_env = {'MOZHARNESS_TEST_PATHS': six.ensure_text(
384
json.dumps(resolve_tests_by_suite(test_files)))}
385
try_config.setdefault('env', {}).update(path_env)
386
387
# Build commit message.
388
msg = 'try coverage - ' + test_count_message
389
return push_to_try('coverage', message.format(msg=msg),
390
try_task_config=generate_try_task_config('coverage', tasks, try_config),
391
push=push, closed_tree=closed_tree)