blob: 444020ce93be7224f5b553c77844e682807fb71a [file] [log] [blame]
George Burgess IV78eb66d2019-03-11 13:53:20 -07001#!/usr/bin/env python2
2# -*- coding: utf-8 -*-
3#
4# Copyright 2019 The Chromium OS Authors. All rights reserved.
5# Use of this source code is governed by a BSD-style license that can be
6# found in the LICENSE file.
7
8"""Runs tests for the given input files.
9
10Tries its best to autodetect all tests based on path name without being *too*
11aggressive.
12
13In short, there's a small set of directories in which, if you make any change,
14all of the tests in those directories get run. Additionally, if you change a
15python file named foo, it'll run foo_test.py or foo_unittest.py if either of
16those exist.
17
18All tests are run in parallel.
19"""
20
21# NOTE: An alternative mentioned on the initial CL for this
22# https://chromium-review.googlesource.com/c/chromiumos/third_party/toolchain-utils/+/1516414
23# is pytest. It looks like that brings some complexity (and makes use outside
24# of the chroot a bit more obnoxious?), but might be worth exploring if this
25# starts to grow quite complex on its own.
26
27from __future__ import print_function
28
29import argparse
30import collections
31import contextlib
32import multiprocessing.pool
33import os
34import pipes
35import subprocess
36import sys
37
38TestSpec = collections.namedtuple('TestSpec', ['directory', 'command'])
39
40
41def _make_relative_to_toolchain_utils(toolchain_utils, path):
42 """Cleans & makes a path relative to toolchain_utils.
43
44 Raises if that path isn't under toolchain_utils.
45 """
46 # abspath has the nice property that it removes any markers like './'.
47 as_abs = os.path.abspath(path)
48 result = os.path.relpath(as_abs, start=toolchain_utils)
49
50 if result.startswith('../'):
51 raise ValueError('Non toolchain-utils directory found: %s' % result)
52 return result
53
54
55def _gather_python_tests_in(subdir):
56 """Returns all files that appear to be Python tests in a given directory."""
57 test_files = (
58 os.path.join(subdir, file_name)
59 for file_name in os.listdir(subdir)
60 if file_name.endswith('_test.py') or file_name.endswith('_unittest.py'))
61 return [_python_test_to_spec(test_file) for test_file in test_files]
62
63
64def _run_test(test_spec):
65 """Runs a test."""
66 p = subprocess.Popen(
67 test_spec.command,
68 cwd=test_spec.directory,
69 stdin=open('/dev/null'),
70 stdout=subprocess.PIPE,
71 stderr=subprocess.STDOUT)
72 stdout, _ = p.communicate()
73 exit_code = p.wait()
74 return exit_code, stdout
75
76
77def _python_test_to_spec(test_file):
78 """Given a .py file, convert it to a TestSpec."""
79 # Run tests in the directory they exist in, since some of them are sensitive
80 # to that.
81 test_directory = os.path.dirname(os.path.abspath(test_file))
82 file_name = os.path.basename(test_file)
83
84 if os.access(test_file, os.X_OK):
85 command = ['./' + file_name]
86 else:
87 # Assume the user wanted py2.
88 command = ['python2', file_name]
89
90 return TestSpec(directory=test_directory, command=command)
91
92
93def _autodetect_python_tests_for(test_file):
94 """Given a test file, detect if there may be related tests."""
95 if not test_file.endswith('.py'):
96 return []
97
98 base = test_file[:-3]
99 candidates = [base + '_test.py', '_unittest.py']
100 tests_to_run = (c for c in candidates if os.path.exists(c))
101 return [_python_test_to_spec(test_file) for test_file in tests_to_run]
102
103
104def _run_test_scripts(all_tests, show_successful_output=False):
105 """Runs a list of TestSpecs. Returns whether all of them succeeded."""
106 with contextlib.closing(multiprocessing.pool.ThreadPool()) as pool:
107 results = [pool.apply_async(_run_test, (test,)) for test in all_tests]
108
109 failures = []
110 for i, (test, future) in enumerate(zip(all_tests, results)):
111 # Add a bit more spacing between outputs.
112 if show_successful_output and i:
113 print('\n')
114
115 pretty_test = ' '.join(pipes.quote(test_arg) for test_arg in test.command)
116 pretty_directory = os.path.relpath(test.directory)
117 if pretty_directory == '.':
118 test_message = pretty_test
119 else:
120 test_message = '%s in %s/' % (pretty_test, pretty_directory)
121
122 print('## %s ... ' % test_message, end='')
123 # Be sure that the users sees which test is running.
124 sys.stdout.flush()
125
126 exit_code, stdout = future.get()
127 if not exit_code:
128 print('PASS')
129 else:
130 print('FAIL')
131 failures.append(pretty_test)
132
133 if show_successful_output or exit_code:
134 sys.stdout.write(stdout)
135
136 if failures:
137 word = 'tests' if len(failures) > 1 else 'test'
138 print('%d %s failed: %s' % (len(failures), word, failures))
139
140 return not failures
141
142
143def _compress_list(l):
144 """Removes consecutive duplicate elements from |l|.
145
146 >>> _compress_list([])
147 []
148 >>> _compress_list([1, 1])
149 [1]
150 >>> _compress_list([1, 2, 1])
151 [1, 2, 1]
152 """
153 result = []
154 for e in l:
155 if result and result[-1] == e:
156 continue
157 result.append(e)
158 return result
159
160
161def _fix_python_path(toolchain_utils):
162 pypath = os.environ.get('PYTHONPATH', '')
163 if pypath:
164 pypath = ':' + pypath
165 os.environ['PYTHONPATH'] = toolchain_utils + pypath
166
167
Tobias Bosch7f186702019-06-20 08:56:58 -0700168def _find_forced_subdir_python_tests(test_paths, toolchain_utils):
George Burgess IV78eb66d2019-03-11 13:53:20 -0700169 assert all(os.path.isabs(path) for path in test_paths)
170
171 # Directories under toolchain_utils for which any change will cause all tests
172 # in that directory to be rerun. Includes changes in subdirectories.
173 all_dirs = {
174 'crosperf',
175 'cros_utils',
176 }
177
178 relative_paths = [
179 _make_relative_to_toolchain_utils(toolchain_utils, path)
180 for path in test_paths
181 ]
182
183 gather_test_dirs = set()
184
185 for path in relative_paths:
186 top_level_dir = path.split('/')[0]
187 if top_level_dir in all_dirs:
188 gather_test_dirs.add(top_level_dir)
189
190 results = []
191 for d in sorted(gather_test_dirs):
192 results += _gather_python_tests_in(os.path.join(toolchain_utils, d))
193 return results
194
195
Tobias Bosch7f186702019-06-20 08:56:58 -0700196def _find_go_tests(test_paths):
197 """Returns TestSpecs for the go folders of the given files"""
198 assert all(os.path.isabs(path) for path in test_paths)
199
200 dirs_with_gofiles = set(
201 os.path.dirname(p) for p in test_paths if p.endswith(".go"))
202 command = ["go", "test", "-vet=all"]
203 # Note: We sort the directories to be deterministic.
204 return [
205 TestSpec(directory=d, command=command) for d in sorted(dirs_with_gofiles)
206 ]
207
208
George Burgess IV78eb66d2019-03-11 13:53:20 -0700209def main(argv):
210 default_toolchain_utils = os.path.abspath(os.path.dirname(__file__))
211
212 parser = argparse.ArgumentParser(description=__doc__)
213 parser.add_argument(
214 '--show_all_output',
215 action='store_true',
216 help='show stdout of successful tests')
217 parser.add_argument(
218 '--toolchain_utils',
219 default=default_toolchain_utils,
220 help='directory of toolchain-utils. Often auto-detected')
221 parser.add_argument(
222 'file', nargs='*', help='a file that we should run tests for')
223 args = parser.parse_args(argv)
224
225 modified_files = [os.path.abspath(f) for f in args.file]
226 show_all_output = args.show_all_output
227 toolchain_utils = args.toolchain_utils
228
229 if not modified_files:
230 print('No files given. Exit.')
231 return 0
232
233 _fix_python_path(toolchain_utils)
234
Tobias Bosch7f186702019-06-20 08:56:58 -0700235 tests_to_run = _find_forced_subdir_python_tests(modified_files,
236 toolchain_utils)
George Burgess IV78eb66d2019-03-11 13:53:20 -0700237 for f in modified_files:
238 tests_to_run += _autodetect_python_tests_for(f)
Tobias Bosch7f186702019-06-20 08:56:58 -0700239 tests_to_run += _find_go_tests(modified_files)
George Burgess IV78eb66d2019-03-11 13:53:20 -0700240
241 # TestSpecs have lists, so we can't use a set. We'd likely want to keep them
242 # sorted for determinism anyway.
243 tests_to_run.sort()
244 tests_to_run = _compress_list(tests_to_run)
245
246 success = _run_test_scripts(tests_to_run, show_all_output)
247 return 0 if success else 1
248
249
250if __name__ == '__main__':
251 sys.exit(main(sys.argv[1:]))