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