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 | |
| 42 | _OTHER_TEST_TARGETS = [ |
| 43 | '//chrome/test:browser_tests', |
| 44 | ] |
| 45 | |
| 46 | |
| 47 | class 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 | |
| 65 | def 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 | |
| 75 | def 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 | |
| 87 | def 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 | |
| 99 | def 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 | |
| 123 | def 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 | |
| 140 | def 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 | |
| 147 | def 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 | |
| 165 | def 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 | |
| 188 | def 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 | |
| 202 | def 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 | |
| 242 | if __name__ == '__main__': |
| 243 | sys.exit(main()) |