blob: 00b3584b9ecebc249b93b0e0134c9302cb782584 [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
7This script finds the appropriate test suite for the specified test file, builds
8it, then runs it with the (optionally) specified filter, passing any extra args
9on to the test runner.
10
11Examples:
12autotest.py -C out/Desktop bit_cast_unittest.cc --gtest_filter=BitCastTest* -v
13autotest.py -C out/Android UrlUtilitiesUnitTest --fast-local-dev -v
14"""
15
16import argparse
17import locale
18import logging
19import multiprocessing
20import os
21import re
22import shlex
23import subprocess
24import sys
25
26from pathlib import Path
27
28USE_PYTHON_3 = f'This script will only run under python3.'
29
30SRC_DIR = Path(__file__).parent.parent.resolve()
31DEPOT_TOOLS_DIR = SRC_DIR.joinpath('third_party', 'depot_tools')
32DEBUG = False
33
34_TEST_TARGET_SUFFIXES = [
35 '_browsertests',
36 '_junit_tests',
37 '_perftests',
38 '_test_apk',
39 '_unittests',
40]
41
42_OTHER_TEST_TARGETS = [
43 '//chrome/test:browser_tests',
44]
45
46
47class CommandError(Exception):
48 """Exception thrown when we can't parse the input file."""
49
50 def __init__(self, command, return_code, output=None):
51 Exception.__init__(self)
52 self.command = command
53 self.return_code = return_code
54 self.output = output
55
56 def __str__(self):
57 message = (f'\n***\nERROR: Error while running command {self.command}'
58 f'.\nExit status: {self.return_code}\n')
59 if self.output:
60 message += f'Output:\n{self.output}\n'
61 message += '***'
62 return message
63
64
65def LogCommand(cmd, **kwargs):
66 if DEBUG:
67 print('Running command: ' + ' '.join(cmd))
68
69 try:
70 subprocess.check_call(cmd, **kwargs)
71 except subprocess.CalledProcessError as e:
72 raise CommandError(e.cmd, e.returncode) from None
73
74
75def RunCommand(cmd, **kwargs):
76 if DEBUG:
77 print('Running command: ' + ' '.join(cmd))
78
79 try:
80 # Set an encoding to convert the binary output to a string.
81 return subprocess.check_output(
82 cmd, **kwargs, encoding=locale.getpreferredencoding())
83 except subprocess.CalledProcessError as e:
84 raise CommandError(e.cmd, e.returncode, e.output) from None
85
86
87def BuildTestTargetWithNinja(out_dir, target, dry_run):
88 """Builds the specified target with ninja"""
89 ninja_path = os.path.join(DEPOT_TOOLS_DIR, 'autoninja')
90 if 'win' in sys.platform:
91 ninja_path += '.bat'
92 cmd = [ninja_path, '-C', out_dir, target]
93 print('Building: ' + ' '.join(cmd))
94 if (dry_run):
95 return
96 RunCommand(cmd)
97
98
99def RecursiveMatchFilename(folder, filename):
100 current_dir = os.path.split(folder)[-1]
101 if current_dir.startswith('out') or current_dir.startswith('.'):
102 return []
103 matches = []
104 with os.scandir(folder) as it:
105 for entry in it:
106 if (entry.is_symlink()):
107 continue
108 if entry.is_file() and filename in entry.path:
109 matches.append(entry.path)
110 if entry.is_dir():
111 # On Windows, junctions are like a symlink that python interprets as a
112 # directory, leading to exceptions being thrown. We can just catch and
113 # ignore these exceptions like we would ignore symlinks.
114 try:
115 matches += RecursiveMatchFilename(entry.path, filename)
116 except FileNotFoundError as e:
117 if DEBUG:
118 print(f'Failed to scan directory "{entry}" - junction?')
119 pass
120 return matches
121
122
123def FindMatchingTestFile(target):
124 if DEBUG:
125 print('Finding files with full path containing: ' + target)
126 results = RecursiveMatchFilename(SRC_DIR, target)
127 if DEBUG:
128 print('Found matching file(s): ' + ' '.join(results))
129 if len(results) > 1:
130 # Arbitrarily capping at 10 results so we don't print the name of every file
131 # in the repo if the target is poorly specified.
132 results = results[:10]
133 raise Exception(f'Target "{target}" is ambiguous. Matching files: '
134 f'{results}')
135 if not results:
136 raise Exception(f'Target "{target}" did not match any files.')
137 return results[0]
138
139
140def IsTestTarget(target):
141 for suffix in _TEST_TARGET_SUFFIXES:
142 if target.endswith(suffix):
143 return True
144 return target in _OTHER_TEST_TARGETS
145
146
147def HaveUserPickTarget(path, targets):
148 # Cap to 10 targets for convenience [0-9].
149 targets = targets[:10]
150 target_list = ''
151 i = 0
152 for target in targets:
153 target_list += f'{i}. {target}\n'
154 i += 1
155 try:
156 value = int(
157 input(f'Target "{path}" is used by multiple test targets.\n' +
158 target_list + 'Please pick a target: '))
159 return targets[value]
160 except Exception as e:
161 print('Try again')
162 return HaveUserPickTarget(path, targets)
163
164
165def FindTestTarget(out_dir, path):
166 # Use gn refs to recursively find all targets that depend on |path|, filter
167 # internal gn targets, and match against well-known test suffixes, falling
168 # back to a list of known test targets if that fails.
169 gn_path = os.path.join(DEPOT_TOOLS_DIR, 'gn')
170 if 'win' in sys.platform:
171 gn_path += '.bat'
172 cmd = [gn_path, 'refs', out_dir, '--all', path]
173 targets = RunCommand(cmd, cwd=SRC_DIR).splitlines()
174 targets = [t for t in targets if '__' not in t]
175 test_targets = [t for t in targets if IsTestTarget(t)]
176
177 if not test_targets:
178 raise Exception(
179 f'Target "{path}" did not match any test targets. Consider adding '
180 f'one of the following targets to the top of this file: {targets}')
181 target = test_targets[0]
182 if len(test_targets) > 1:
183 target = HaveUserPickTarget(path, test_targets)
184
185 return target.split(':')[-1]
186
187
188def RunTestTarget(out_dir, target, gtest_filter, extra_args, dry_run):
189 # Look for the Android wrapper script first.
190 path = os.path.join(out_dir, 'bin', f'run_{target}')
191 if not os.path.isfile(path):
192 # Otherwise, use the Desktop target which is an executable.
193 path = os.path.join(out_dir, target)
194 extra_args = ' '.join(extra_args)
195 cmd = [path, f'--gtest_filter={gtest_filter}'] + shlex.split(extra_args)
196 print('Running test: ' + ' '.join(cmd))
197 if (dry_run):
198 return
199 LogCommand(cmd)
200
201
202def main():
203 parser = argparse.ArgumentParser(
204 description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
205 parser.add_argument(
206 '--out-dir',
207 '-C',
208 metavar='OUT_DIR',
209 help='output directory of the build',
210 required=True)
211 parser.add_argument(
212 '--gtest_filter', '-f', metavar='FILTER', help='test filter')
213 parser.add_argument(
214 '--dry_run',
215 '-n',
216 action='store_true',
217 help='Print ninja and test run commands without executing them.')
218 parser.add_argument(
219 'file', metavar='FILE_NAME', help='test suite file (eg. FooTest.java)')
220
221 args, _extras = parser.parse_known_args()
222
223 if not os.path.isdir(args.out_dir):
224 parser.error(f'OUT_DIR "{args.out_dir}" does not exist.')
225 filename = FindMatchingTestFile(args.file)
226
227 gtest_filter = args.gtest_filter
228 if not gtest_filter:
229 if not filename.endswith('java'):
230 # In c++ tests, the test class often doesn't match the filename, or a
231 # single file will contain multiple test classes. It's likely possible to
232 # handle most cases with a regex and provide a default here.
233 # Patches welcome :)
234 parser.error('--gtest_filter must be specified for non-java tests.')
235 gtest_filter = '*' + os.path.splitext(os.path.basename(filename))[0] + '*'
236
237 target = FindTestTarget(args.out_dir, filename)
238 BuildTestTargetWithNinja(args.out_dir, target, args.dry_run)
239 RunTestTarget(args.out_dir, target, gtest_filter, _extras, args.dry_run)
240
241
242if __name__ == '__main__':
243 sys.exit(main())