blob: daf8d6729a0ecfd261fd9d126b8ab4c925108812 [file] [log] [blame]
Michael Thiessen09c0e1d2020-03-23 18:44:501#!/usr/bin/env python3
2# Copyright 2020 The Chromium Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5"""Builds and runs a test by filename.
6
Dan Harrington27d104d2020-09-08 18:30:147This script finds the appropriate test suites for the specified test file or
8directory, builds it, then runs it with the (optionally) specified filter,
9passing any extra args on to the test runner.
Michael Thiessen09c0e1d2020-03-23 18:44:5010
11Examples:
Dan Harrington27d104d2020-09-08 18:30:1412# Run the test target for bit_cast_unittest.cc. Use a custom test filter instead
13# of the automatically generated one.
14autotest.py -C out/Desktop bit_cast_unittest.cc --gtest_filter=BitCastTest*
15
16# Find and run UrlUtilitiesUnitTest.java's tests, pass remaining parameters to
17# the test binary.
Michael Thiessen09c0e1d2020-03-23 18:44:5018autotest.py -C out/Android UrlUtilitiesUnitTest --fast-local-dev -v
Dan Harrington27d104d2020-09-08 18:30:1419
20# Run all tests under base/strings
21autotest.py -C out/foo --run-all base/strings
22
23# Run only the test on line 11. Useful when running autotest.py from your text
24# editor.
25autotest.py -C out/foo --line 11 base/strings/strcat_unittest.cc
Michael Thiessen09c0e1d2020-03-23 18:44:5026"""
27
28import argparse
29import locale
Michael Thiessen09c0e1d2020-03-23 18:44:5030import os
Dan Harrington27d104d2020-09-08 18:30:1431import json
Michael Thiessen09c0e1d2020-03-23 18:44:5032import re
Michael Thiessen09c0e1d2020-03-23 18:44:5033import subprocess
34import sys
35
36from pathlib import Path
37
38USE_PYTHON_3 = f'This script will only run under python3.'
39
40SRC_DIR = Path(__file__).parent.parent.resolve()
41DEPOT_TOOLS_DIR = SRC_DIR.joinpath('third_party', 'depot_tools')
42DEBUG = False
43
44_TEST_TARGET_SUFFIXES = [
45 '_browsertests',
46 '_junit_tests',
47 '_perftests',
48 '_test_apk',
49 '_unittests',
50]
51
Michael Thiessenf46171e2020-03-31 17:29:3852# Some test suites use suffixes that would also match non-test-suite targets.
53# Those test suites should be manually added here.
Michael Thiessen09c0e1d2020-03-23 18:44:5054_OTHER_TEST_TARGETS = [
55 '//chrome/test:browser_tests',
Michael Thiessenf46171e2020-03-31 17:29:3856 '//chrome/test:unit_tests',
Michael Thiessen09c0e1d2020-03-23 18:44:5057]
58
Dan Harrington27d104d2020-09-08 18:30:1459TEST_FILE_NAME_REGEX = re.compile(r'(.*Test\.java)|(.*_[a-z]*test\.cc)')
60GTEST_INCLUDE_REGEX = re.compile(r'#include.*gtest\.h"')
61
62
63def ExitWithMessage(*args):
64 print(*args, file=sys.stderr)
65 sys.exit(1)
66
67
68def IsTestFile(file_path):
69 if not TEST_FILE_NAME_REGEX.match(file_path):
70 return False
71 if file_path.endswith('.cc'):
72 # Try a bit harder to remove non-test files for c++. Without this,
73 # 'autotest.py base/' finds non-test files.
74 try:
75 with open(file_path, 'r') as f:
76 if GTEST_INCLUDE_REGEX.search(f.read()) is not None:
77 return True
78 except IOError:
79 pass
80 return False
81 return True
82
Michael Thiessen09c0e1d2020-03-23 18:44:5083
84class CommandError(Exception):
Dan Harrington27d104d2020-09-08 18:30:1485 """Exception thrown when a subcommand fails."""
Michael Thiessen09c0e1d2020-03-23 18:44:5086
87 def __init__(self, command, return_code, output=None):
88 Exception.__init__(self)
89 self.command = command
90 self.return_code = return_code
91 self.output = output
92
93 def __str__(self):
94 message = (f'\n***\nERROR: Error while running command {self.command}'
95 f'.\nExit status: {self.return_code}\n')
96 if self.output:
97 message += f'Output:\n{self.output}\n'
98 message += '***'
99 return message
100
101
Dan Harrington27d104d2020-09-08 18:30:14102def StreamCommandOrExit(cmd, **kwargs):
Michael Thiessen09c0e1d2020-03-23 18:44:50103 try:
104 subprocess.check_call(cmd, **kwargs)
105 except subprocess.CalledProcessError as e:
Dan Harrington27d104d2020-09-08 18:30:14106 sys.exit(1)
Michael Thiessen09c0e1d2020-03-23 18:44:50107
108
109def RunCommand(cmd, **kwargs):
Michael Thiessen09c0e1d2020-03-23 18:44:50110 try:
111 # Set an encoding to convert the binary output to a string.
112 return subprocess.check_output(
113 cmd, **kwargs, encoding=locale.getpreferredencoding())
114 except subprocess.CalledProcessError as e:
115 raise CommandError(e.cmd, e.returncode, e.output) from None
116
117
Dan Harrington27d104d2020-09-08 18:30:14118def BuildTestTargetsWithNinja(out_dir, targets, dry_run):
119 """Builds the specified targets with ninja"""
Michael Thiessen09c0e1d2020-03-23 18:44:50120 ninja_path = os.path.join(DEPOT_TOOLS_DIR, 'autoninja')
K. Moon01548662020-04-03 00:14:25121 if sys.platform.startswith('win32'):
Michael Thiessen09c0e1d2020-03-23 18:44:50122 ninja_path += '.bat'
Dan Harrington27d104d2020-09-08 18:30:14123 cmd = [ninja_path, '-C', out_dir] + targets
Michael Thiessen09c0e1d2020-03-23 18:44:50124 print('Building: ' + ' '.join(cmd))
125 if (dry_run):
126 return
Dan Harringtonaa2c7ba2020-09-16 15:34:24127 try:
128 subprocess.check_call(cmd)
129 except subprocess.CalledProcessError as e:
130 return False
131 return True
Michael Thiessen09c0e1d2020-03-23 18:44:50132
133
134def RecursiveMatchFilename(folder, filename):
135 current_dir = os.path.split(folder)[-1]
136 if current_dir.startswith('out') or current_dir.startswith('.'):
137 return []
138 matches = []
139 with os.scandir(folder) as it:
140 for entry in it:
141 if (entry.is_symlink()):
142 continue
Michael Thiessen0264afc62020-04-03 20:31:34143 if (entry.is_file() and filename in entry.path and
144 not os.path.basename(entry.path).startswith('.')):
Dan Harringtonaa2c7ba2020-09-16 15:34:24145 if IsTestFile(entry.path):
146 matches.append(entry.path)
Michael Thiessen09c0e1d2020-03-23 18:44:50147 if entry.is_dir():
148 # On Windows, junctions are like a symlink that python interprets as a
149 # directory, leading to exceptions being thrown. We can just catch and
150 # ignore these exceptions like we would ignore symlinks.
151 try:
152 matches += RecursiveMatchFilename(entry.path, filename)
153 except FileNotFoundError as e:
154 if DEBUG:
155 print(f'Failed to scan directory "{entry}" - junction?')
156 pass
157 return matches
158
159
Dan Harrington27d104d2020-09-08 18:30:14160def FindTestFilesInDirectory(directory):
161 test_files = []
162 for root, dirs, files in os.walk(directory):
163 for f in files:
164 path = os.path.join(root, f)
165 if IsTestFile(path):
166 test_files.append(path)
167 return test_files
168
169
170def FindMatchingTestFiles(target):
171 # Return early if there's an exact file match.
172 if os.path.isfile(target):
Dan Harringtonaa2c7ba2020-09-16 15:34:24173 # If the target is a C++ implementation file, try to guess the test file.
174 if target.endswith('.cc') or target.endswith('.h'):
175 if IsTestFile(target):
176 return [target]
177 alternate = f"{target.rsplit('.', 1)[0]}_unittest.cc"
178 if os.path.isfile(alternate) and IsTestFile(alternate):
179 return [alternate]
180 ExitWithMessage(f"{target} doesn't look like a test file")
Dan Harrington27d104d2020-09-08 18:30:14181 return [target]
182 # If this is a directory, return all the test files it contains.
183 if os.path.isdir(target):
184 files = FindTestFilesInDirectory(target)
185 if not files:
186 ExitWithMessage('No tests found in directory')
187 return files
188
Jesse McKenna83b6ac1b2020-05-07 18:25:38189 if sys.platform.startswith('win32') and os.path.altsep in target:
190 # Use backslash as the path separator on Windows to match os.scandir().
191 if DEBUG:
192 print('Replacing ' + os.path.altsep + ' with ' + os.path.sep + ' in: '
193 + target)
194 target = target.replace(os.path.altsep, os.path.sep)
Michael Thiessen09c0e1d2020-03-23 18:44:50195 if DEBUG:
196 print('Finding files with full path containing: ' + target)
197 results = RecursiveMatchFilename(SRC_DIR, target)
198 if DEBUG:
199 print('Found matching file(s): ' + ' '.join(results))
200 if len(results) > 1:
201 # Arbitrarily capping at 10 results so we don't print the name of every file
202 # in the repo if the target is poorly specified.
203 results = results[:10]
Dan Harrington27d104d2020-09-08 18:30:14204 ExitWithMessage(f'Target "{target}" is ambiguous. Matching files: '
Michael Thiessen09c0e1d2020-03-23 18:44:50205 f'{results}')
206 if not results:
Dan Harrington27d104d2020-09-08 18:30:14207 ExitWithMessage(f'Target "{target}" did not match any files.')
208 return results
Michael Thiessen09c0e1d2020-03-23 18:44:50209
210
211def IsTestTarget(target):
212 for suffix in _TEST_TARGET_SUFFIXES:
213 if target.endswith(suffix):
214 return True
215 return target in _OTHER_TEST_TARGETS
216
217
Dan Harrington27d104d2020-09-08 18:30:14218def HaveUserPickTarget(paths, targets):
Michael Thiessen09c0e1d2020-03-23 18:44:50219 # Cap to 10 targets for convenience [0-9].
220 targets = targets[:10]
Dan Harrington27d104d2020-09-08 18:30:14221 target_list = '\n'.join(f'{i}. {t}' for i, t in enumerate(targets))
222
223 user_input = input(f'Target "{paths}" is used by multiple test targets.\n' +
224 target_list + '\nPlease pick a target: ')
Michael Thiessen09c0e1d2020-03-23 18:44:50225 try:
Dan Harrington27d104d2020-09-08 18:30:14226 value = int(user_input)
Michael Thiessen09c0e1d2020-03-23 18:44:50227 return targets[value]
Dan Harrington27d104d2020-09-08 18:30:14228 except (ValueError, IndexError):
Michael Thiessen09c0e1d2020-03-23 18:44:50229 print('Try again')
Dan Harrington27d104d2020-09-08 18:30:14230 return HaveUserPickTarget(paths, targets)
Michael Thiessen09c0e1d2020-03-23 18:44:50231
232
Dan Harrington27d104d2020-09-08 18:30:14233# A persistent cache to avoid running gn on repeated runs of autotest.
234class TargetCache:
235 def __init__(self, out_dir):
Dan Harringtonaa2c7ba2020-09-16 15:34:24236 self.out_dir = out_dir
Dan Harrington27d104d2020-09-08 18:30:14237 self.path = os.path.join(out_dir, 'autotest_cache')
Dan Harringtonaa2c7ba2020-09-16 15:34:24238 self.gold_mtime = self.GetBuildNinjaMtime()
Dan Harrington27d104d2020-09-08 18:30:14239 self.cache = {}
240 try:
241 mtime, cache = json.load(open(self.path, 'r'))
242 if mtime == self.gold_mtime:
243 self.cache = cache
244 except Exception:
245 pass
246
247 def Save(self):
248 with open(self.path, 'w') as f:
249 json.dump([self.gold_mtime, self.cache], f)
250
251 def Find(self, test_paths):
252 key = ' '.join(test_paths)
253 return self.cache.get(key, None)
254
255 def Store(self, test_paths, test_targets):
256 key = ' '.join(test_paths)
257 self.cache[key] = test_targets
258
Dan Harringtonaa2c7ba2020-09-16 15:34:24259 def GetBuildNinjaMtime(self):
260 return os.path.getmtime(os.path.join(self.out_dir, 'build.ninja'))
261
262 def IsStillValid(self):
263 return self.GetBuildNinjaMtime() == self.gold_mtime
264
Dan Harrington27d104d2020-09-08 18:30:14265
266def FindTestTargets(target_cache, out_dir, paths, run_all):
267 # Normalize paths, so they can be cached.
268 paths = [os.path.realpath(p) for p in paths]
269 test_targets = target_cache.Find(paths)
Dan Harringtonaa2c7ba2020-09-16 15:34:24270 used_cache = True
Dan Harrington27d104d2020-09-08 18:30:14271 if not test_targets:
Dan Harringtonaa2c7ba2020-09-16 15:34:24272 used_cache = False
Dan Harrington27d104d2020-09-08 18:30:14273
274 # Use gn refs to recursively find all targets that depend on |path|, filter
275 # internal gn targets, and match against well-known test suffixes, falling
276 # back to a list of known test targets if that fails.
277 gn_path = os.path.join(DEPOT_TOOLS_DIR, 'gn')
278 if sys.platform.startswith('win32'):
279 gn_path += '.bat'
280
281 cmd = [gn_path, 'refs', out_dir, '--all'] + paths
282 targets = RunCommand(cmd).splitlines()
283 targets = [t for t in targets if '__' not in t]
284 test_targets = [t for t in targets if IsTestTarget(t)]
Michael Thiessen09c0e1d2020-03-23 18:44:50285
286 if not test_targets:
Dan Harrington27d104d2020-09-08 18:30:14287 ExitWithMessage(
288 f'Target(s) "{paths}" did not match any test targets. Consider adding'
289 f' one of the following targets to the top of this file: {targets}')
290
291 target_cache.Store(paths, test_targets)
292 target_cache.Save()
293
Michael Thiessen09c0e1d2020-03-23 18:44:50294 if len(test_targets) > 1:
Dan Harrington27d104d2020-09-08 18:30:14295 if run_all:
296 print(f'Warning, found {len(test_targets)} test targets.',
297 file=sys.stderr)
298 if len(test_targets) > 10:
299 ExitWithMessage('Your query likely involves non-test sources.')
300 print('Trying to run all of them!', file=sys.stderr)
301 else:
302 test_targets = [HaveUserPickTarget(paths, test_targets)]
Michael Thiessen09c0e1d2020-03-23 18:44:50303
Dan Harrington27d104d2020-09-08 18:30:14304 test_targets = list(set([t.split(':')[-1] for t in test_targets]))
305
Dan Harringtonaa2c7ba2020-09-16 15:34:24306 return (test_targets, used_cache)
Michael Thiessen09c0e1d2020-03-23 18:44:50307
308
Dan Harrington27d104d2020-09-08 18:30:14309def RunTestTargets(out_dir, targets, gtest_filter, extra_args, dry_run):
310 for target in targets:
311 # Look for the Android wrapper script first.
312 path = os.path.join(out_dir, 'bin', f'run_{target}')
313 if not os.path.isfile(path):
314 # Otherwise, use the Desktop target which is an executable.
315 path = os.path.join(out_dir, target)
316 cmd = [path, f'--gtest_filter={gtest_filter}'] + extra_args
317 print('Running test: ' + ' '.join(cmd))
318 if not dry_run:
319 StreamCommandOrExit(cmd)
320
321
322def BuildCppTestFilter(filenames, line):
323 make_filter_command = [os.path.join(SRC_DIR, 'tools', 'make-gtest-filter.py')]
324 if line:
325 make_filter_command += ['--line', str(line)]
326 else:
327 make_filter_command += ['--class-only']
328 make_filter_command += filenames
329 return RunCommand(make_filter_command).strip()
330
331
332def BuildJavaTestFilter(filenames):
333 return ':'.join('*{}*'.format(os.path.splitext(os.path.basename(f))[0])
334 for f in filenames)
335
336
337def BuildTestFilter(filenames, line):
338 java_files = [f for f in filenames if f.endswith('.java')]
339 cc_files = [f for f in filenames if f.endswith('.cc')]
340 filters = []
341 if java_files:
342 filters.append(BuildJavaTestFilter(java_files))
343 if cc_files:
344 filters.append(BuildCppTestFilter(cc_files, line))
345
346 return ':'.join(filters)
Michael Thiessen09c0e1d2020-03-23 18:44:50347
348
349def main():
350 parser = argparse.ArgumentParser(
351 description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
352 parser.add_argument(
353 '--out-dir',
354 '-C',
355 metavar='OUT_DIR',
356 help='output directory of the build',
357 required=True)
358 parser.add_argument(
Dan Harrington27d104d2020-09-08 18:30:14359 '--run-all',
360 action='store_true',
361 help='Run all tests for the file or directory, instead of just one')
362 parser.add_argument('--line',
363 type=int,
364 help='run only the test on this line number. c++ only.')
365 parser.add_argument(
Michael Thiessen09c0e1d2020-03-23 18:44:50366 '--gtest_filter', '-f', metavar='FILTER', help='test filter')
367 parser.add_argument(
Dan Harrington27d104d2020-09-08 18:30:14368 '--dry-run',
Michael Thiessen09c0e1d2020-03-23 18:44:50369 '-n',
370 action='store_true',
371 help='Print ninja and test run commands without executing them.')
Dan Harringtonaa2c7ba2020-09-16 15:34:24372 parser.add_argument('file',
373 metavar='FILE_NAME',
374 help='test suite file (eg. FooTest.java)')
Michael Thiessen09c0e1d2020-03-23 18:44:50375
376 args, _extras = parser.parse_known_args()
377
378 if not os.path.isdir(args.out_dir):
379 parser.error(f'OUT_DIR "{args.out_dir}" does not exist.')
Dan Harrington27d104d2020-09-08 18:30:14380 target_cache = TargetCache(args.out_dir)
381 filenames = FindMatchingTestFiles(args.file)
382
Dan Harringtonaa2c7ba2020-09-16 15:34:24383 targets, used_cache = FindTestTargets(target_cache, args.out_dir, filenames,
384 args.run_all)
Michael Thiessen09c0e1d2020-03-23 18:44:50385
386 gtest_filter = args.gtest_filter
387 if not gtest_filter:
Dan Harrington27d104d2020-09-08 18:30:14388 gtest_filter = BuildTestFilter(filenames, args.line)
Michael Thiessen09c0e1d2020-03-23 18:44:50389
Dan Harrington27d104d2020-09-08 18:30:14390 if not gtest_filter:
391 ExitWithMessage('Failed to derive a gtest filter')
392
393 assert targets
Dan Harringtonaa2c7ba2020-09-16 15:34:24394 build_ok = BuildTestTargetsWithNinja(args.out_dir, targets, args.dry_run)
395
396 # If we used the target cache, it's possible we chose the wrong target because
397 # a gn file was changed. The build step above will check for gn modifications
398 # and update build.ninja. Use this opportunity the verify the cache is still
399 # valid.
400 if used_cache and not target_cache.IsStillValid():
401 target_cache = TargetCache(args.out_dir)
402 new_targets, _ = FindTestTargets(target_cache, args.out_dir, filenames,
403 args.run_all)
404 if targets != new_targets:
405 # Note that this can happen, for example, if you rename a test target.
406 print('gn config was changed, trying to build again', file=sys.stderr)
407 targets = new_targets
408 if not BuildTestTargetsWithNinja(args.out_dir, targets, args.dry_run):
409 sys.exit(1)
410 else: # cache still valid, quit if the build failed
411 if not build_ok: sys.exit(1)
412
Dan Harrington27d104d2020-09-08 18:30:14413 RunTestTargets(args.out_dir, targets, gtest_filter, _extras, args.dry_run)
Michael Thiessen09c0e1d2020-03-23 18:44:50414
415
416if __name__ == '__main__':
417 sys.exit(main())