| #!/usr/bin/env python3 | 
 | # Copyright 2020 The Chromium Authors. All rights reserved. | 
 | # Use of this source code is governed by a BSD-style license that can be | 
 | # found in the LICENSE file. | 
 | """Builds and runs a test by filename. | 
 |  | 
 | This script finds the appropriate test suites for the specified test file or | 
 | directory, builds it, then runs it with the (optionally) specified filter, | 
 | passing any extra args on to the test runner. | 
 |  | 
 | Examples: | 
 | # Run the test target for bit_cast_unittest.cc. Use a custom test filter instead | 
 | # of the automatically generated one. | 
 | autotest.py -C out/Desktop bit_cast_unittest.cc --gtest_filter=BitCastTest* | 
 |  | 
 | # Find and run UrlUtilitiesUnitTest.java's tests, pass remaining parameters to | 
 | # the test binary. | 
 | autotest.py -C out/Android UrlUtilitiesUnitTest --fast-local-dev -v | 
 |  | 
 | # Run all tests under base/strings | 
 | autotest.py -C out/foo --run-all base/strings | 
 |  | 
 | # Run only the test on line 11. Useful when running autotest.py from your text | 
 | # editor. | 
 | autotest.py -C out/foo --line 11 base/strings/strcat_unittest.cc | 
 | """ | 
 |  | 
 | import argparse | 
 | import locale | 
 | import os | 
 | import json | 
 | import re | 
 | import subprocess | 
 | import sys | 
 |  | 
 | from pathlib import Path | 
 |  | 
 | USE_PYTHON_3 = f'This script will only run under python3.' | 
 |  | 
 | SRC_DIR = Path(__file__).parent.parent.resolve() | 
 | DEPOT_TOOLS_DIR = SRC_DIR.joinpath('third_party', 'depot_tools') | 
 | DEBUG = False | 
 |  | 
 | _TEST_TARGET_SUFFIXES = [ | 
 |     '_browsertests', | 
 |     '_junit_tests', | 
 |     '_perftests', | 
 |     '_test_apk', | 
 |     '_unittests', | 
 | ] | 
 |  | 
 | # Some test suites use suffixes that would also match non-test-suite targets. | 
 | # Those test suites should be manually added here. | 
 | _OTHER_TEST_TARGETS = [ | 
 |     '//chrome/test:browser_tests', | 
 |     '//chrome/test:unit_tests', | 
 | ] | 
 |  | 
 | TEST_FILE_NAME_REGEX = re.compile(r'(.*Test\.java)|(.*_[a-z]*test\.cc)') | 
 | GTEST_INCLUDE_REGEX = re.compile(r'#include.*gtest\.h"') | 
 |  | 
 |  | 
 | def ExitWithMessage(*args): | 
 |   print(*args, file=sys.stderr) | 
 |   sys.exit(1) | 
 |  | 
 |  | 
 | def IsTestFile(file_path): | 
 |   if not TEST_FILE_NAME_REGEX.match(file_path): | 
 |     return False | 
 |   if file_path.endswith('.cc'): | 
 |     # Try a bit harder to remove non-test files for c++. Without this, | 
 |     # 'autotest.py base/' finds non-test files. | 
 |     try: | 
 |       with open(file_path, 'r') as f: | 
 |         if GTEST_INCLUDE_REGEX.search(f.read()) is not None: | 
 |           return True | 
 |     except IOError: | 
 |       pass | 
 |     return False | 
 |   return True | 
 |  | 
 |  | 
 | class CommandError(Exception): | 
 |   """Exception thrown when a subcommand fails.""" | 
 |  | 
 |   def __init__(self, command, return_code, output=None): | 
 |     Exception.__init__(self) | 
 |     self.command = command | 
 |     self.return_code = return_code | 
 |     self.output = output | 
 |  | 
 |   def __str__(self): | 
 |     message = (f'\n***\nERROR: Error while running command {self.command}' | 
 |                f'.\nExit status: {self.return_code}\n') | 
 |     if self.output: | 
 |       message += f'Output:\n{self.output}\n' | 
 |     message += '***' | 
 |     return message | 
 |  | 
 |  | 
 | def StreamCommandOrExit(cmd, **kwargs): | 
 |   try: | 
 |     subprocess.check_call(cmd, **kwargs) | 
 |   except subprocess.CalledProcessError as e: | 
 |     sys.exit(1) | 
 |  | 
 |  | 
 | def RunCommand(cmd, **kwargs): | 
 |   try: | 
 |     # Set an encoding to convert the binary output to a string. | 
 |     return subprocess.check_output( | 
 |         cmd, **kwargs, encoding=locale.getpreferredencoding()) | 
 |   except subprocess.CalledProcessError as e: | 
 |     raise CommandError(e.cmd, e.returncode, e.output) from None | 
 |  | 
 |  | 
 | def BuildTestTargetsWithNinja(out_dir, targets, dry_run): | 
 |   """Builds the specified targets with ninja""" | 
 |   # Use autoninja from PATH to match version used for manual builds. | 
 |   ninja_path = 'autoninja' | 
 |   if sys.platform.startswith('win32'): | 
 |     ninja_path += '.bat' | 
 |   cmd = [ninja_path, '-C', out_dir] + targets | 
 |   print('Building: ' + ' '.join(cmd)) | 
 |   if (dry_run): | 
 |     return | 
 |   try: | 
 |     subprocess.check_call(cmd) | 
 |   except subprocess.CalledProcessError as e: | 
 |     return False | 
 |   return True | 
 |  | 
 |  | 
 | def RecursiveMatchFilename(folder, filename): | 
 |   current_dir = os.path.split(folder)[-1] | 
 |   if current_dir.startswith('out') or current_dir.startswith('.'): | 
 |     return [] | 
 |   matches = [] | 
 |   with os.scandir(folder) as it: | 
 |     for entry in it: | 
 |       if (entry.is_symlink()): | 
 |         continue | 
 |       if (entry.is_file() and filename in entry.path and | 
 |           not os.path.basename(entry.path).startswith('.')): | 
 |         if IsTestFile(entry.path): | 
 |           matches.append(entry.path) | 
 |       if entry.is_dir(): | 
 |         # On Windows, junctions are like a symlink that python interprets as a | 
 |         # directory, leading to exceptions being thrown. We can just catch and | 
 |         # ignore these exceptions like we would ignore symlinks. | 
 |         try: | 
 |           matches += RecursiveMatchFilename(entry.path, filename) | 
 |         except FileNotFoundError as e: | 
 |           if DEBUG: | 
 |             print(f'Failed to scan directory "{entry}" - junction?') | 
 |           pass | 
 |   return matches | 
 |  | 
 |  | 
 | def FindTestFilesInDirectory(directory): | 
 |   test_files = [] | 
 |   for root, dirs, files in os.walk(directory): | 
 |     for f in files: | 
 |       path = os.path.join(root, f) | 
 |       if IsTestFile(path): | 
 |         test_files.append(path) | 
 |   return test_files | 
 |  | 
 |  | 
 | def FindMatchingTestFiles(target): | 
 |   # Return early if there's an exact file match. | 
 |   if os.path.isfile(target): | 
 |     # If the target is a C++ implementation file, try to guess the test file. | 
 |     if target.endswith('.cc') or target.endswith('.h'): | 
 |       if IsTestFile(target): | 
 |         return [target] | 
 |       alternate = f"{target.rsplit('.', 1)[0]}_unittest.cc" | 
 |       if os.path.isfile(alternate) and IsTestFile(alternate): | 
 |         return [alternate] | 
 |       ExitWithMessage(f"{target} doesn't look like a test file") | 
 |     return [target] | 
 |   # If this is a directory, return all the test files it contains. | 
 |   if os.path.isdir(target): | 
 |     files = FindTestFilesInDirectory(target) | 
 |     if not files: | 
 |       ExitWithMessage('No tests found in directory') | 
 |     return files | 
 |  | 
 |   if sys.platform.startswith('win32') and os.path.altsep in target: | 
 |     # Use backslash as the path separator on Windows to match os.scandir(). | 
 |     if DEBUG: | 
 |       print('Replacing ' + os.path.altsep + ' with ' + os.path.sep + ' in: ' | 
 |             + target) | 
 |     target = target.replace(os.path.altsep, os.path.sep) | 
 |   if DEBUG: | 
 |     print('Finding files with full path containing: ' + target) | 
 |   results = RecursiveMatchFilename(SRC_DIR, target) | 
 |   if DEBUG: | 
 |     print('Found matching file(s): ' + ' '.join(results)) | 
 |   if len(results) > 1: | 
 |     # Arbitrarily capping at 10 results so we don't print the name of every file | 
 |     # in the repo if the target is poorly specified. | 
 |     results = results[:10] | 
 |     ExitWithMessage(f'Target "{target}" is ambiguous. Matching files: ' | 
 |                     f'{results}') | 
 |   if not results: | 
 |     ExitWithMessage(f'Target "{target}" did not match any files.') | 
 |   return results | 
 |  | 
 |  | 
 | def IsTestTarget(target): | 
 |   for suffix in _TEST_TARGET_SUFFIXES: | 
 |     if target.endswith(suffix): | 
 |       return True | 
 |   return target in _OTHER_TEST_TARGETS | 
 |  | 
 |  | 
 | def HaveUserPickTarget(paths, targets): | 
 |   # Cap to 10 targets for convenience [0-9]. | 
 |   targets = targets[:10] | 
 |   target_list = '\n'.join(f'{i}. {t}' for i, t in enumerate(targets)) | 
 |  | 
 |   user_input = input(f'Target "{paths}" is used by multiple test targets.\n' + | 
 |                      target_list + '\nPlease pick a target: ') | 
 |   try: | 
 |     value = int(user_input) | 
 |     return targets[value] | 
 |   except (ValueError, IndexError): | 
 |     print('Try again') | 
 |     return HaveUserPickTarget(paths, targets) | 
 |  | 
 |  | 
 | # A persistent cache to avoid running gn on repeated runs of autotest. | 
 | class TargetCache: | 
 |   def __init__(self, out_dir): | 
 |     self.out_dir = out_dir | 
 |     self.path = os.path.join(out_dir, 'autotest_cache') | 
 |     self.gold_mtime = self.GetBuildNinjaMtime() | 
 |     self.cache = {} | 
 |     try: | 
 |       mtime, cache = json.load(open(self.path, 'r')) | 
 |       if mtime == self.gold_mtime: | 
 |         self.cache = cache | 
 |     except Exception: | 
 |       pass | 
 |  | 
 |   def Save(self): | 
 |     with open(self.path, 'w') as f: | 
 |       json.dump([self.gold_mtime, self.cache], f) | 
 |  | 
 |   def Find(self, test_paths): | 
 |     key = ' '.join(test_paths) | 
 |     return self.cache.get(key, None) | 
 |  | 
 |   def Store(self, test_paths, test_targets): | 
 |     key = ' '.join(test_paths) | 
 |     self.cache[key] = test_targets | 
 |  | 
 |   def GetBuildNinjaMtime(self): | 
 |     return os.path.getmtime(os.path.join(self.out_dir, 'build.ninja')) | 
 |  | 
 |   def IsStillValid(self): | 
 |     return self.GetBuildNinjaMtime() == self.gold_mtime | 
 |  | 
 |  | 
 | def FindTestTargets(target_cache, out_dir, paths, run_all): | 
 |   # Normalize paths, so they can be cached. | 
 |   paths = [os.path.realpath(p) for p in paths] | 
 |   test_targets = target_cache.Find(paths) | 
 |   used_cache = True | 
 |   if not test_targets: | 
 |     used_cache = False | 
 |  | 
 |     # Use gn refs to recursively find all targets that depend on |path|, filter | 
 |     # internal gn targets, and match against well-known test suffixes, falling | 
 |     # back to a list of known test targets if that fails. | 
 |     gn_path = os.path.join(DEPOT_TOOLS_DIR, 'gn') | 
 |     if sys.platform.startswith('win32'): | 
 |       gn_path += '.bat' | 
 |  | 
 |     cmd = [gn_path, 'refs', out_dir, '--all'] + paths | 
 |     targets = RunCommand(cmd).splitlines() | 
 |     targets = [t for t in targets if '__' not in t] | 
 |     test_targets = [t for t in targets if IsTestTarget(t)] | 
 |  | 
 |   if not test_targets: | 
 |     ExitWithMessage( | 
 |         f'Target(s) "{paths}" did not match any test targets. Consider adding' | 
 |         f' one of the following targets to the top of this file: {targets}') | 
 |  | 
 |   target_cache.Store(paths, test_targets) | 
 |   target_cache.Save() | 
 |  | 
 |   if len(test_targets) > 1: | 
 |     if run_all: | 
 |       print(f'Warning, found {len(test_targets)} test targets.', | 
 |             file=sys.stderr) | 
 |       if len(test_targets) > 10: | 
 |         ExitWithMessage('Your query likely involves non-test sources.') | 
 |       print('Trying to run all of them!', file=sys.stderr) | 
 |     else: | 
 |       test_targets = [HaveUserPickTarget(paths, test_targets)] | 
 |  | 
 |   test_targets = list(set([t.split(':')[-1] for t in test_targets])) | 
 |  | 
 |   return (test_targets, used_cache) | 
 |  | 
 |  | 
 | def RunTestTargets(out_dir, targets, gtest_filter, extra_args, dry_run): | 
 |   for target in targets: | 
 |     # Look for the Android wrapper script first. | 
 |     path = os.path.join(out_dir, 'bin', f'run_{target}') | 
 |     if not os.path.isfile(path): | 
 |       # Otherwise, use the Desktop target which is an executable. | 
 |       path = os.path.join(out_dir, target) | 
 |     cmd = [path, f'--gtest_filter={gtest_filter}'] + extra_args | 
 |     print('Running test: ' + ' '.join(cmd)) | 
 |     if not dry_run: | 
 |       StreamCommandOrExit(cmd) | 
 |  | 
 |  | 
 | def BuildCppTestFilter(filenames, line): | 
 |   make_filter_command = [os.path.join(SRC_DIR, 'tools', 'make-gtest-filter.py')] | 
 |   if line: | 
 |     make_filter_command += ['--line', str(line)] | 
 |   else: | 
 |     make_filter_command += ['--class-only'] | 
 |   make_filter_command += filenames | 
 |   return RunCommand(make_filter_command).strip() | 
 |  | 
 |  | 
 | def BuildJavaTestFilter(filenames): | 
 |   return ':'.join('*.{}*'.format(os.path.splitext(os.path.basename(f))[0]) | 
 |                   for f in filenames) | 
 |  | 
 |  | 
 | def BuildTestFilter(filenames, line): | 
 |   java_files = [f for f in filenames if f.endswith('.java')] | 
 |   cc_files = [f for f in filenames if f.endswith('.cc')] | 
 |   filters = [] | 
 |   if java_files: | 
 |     filters.append(BuildJavaTestFilter(java_files)) | 
 |   if cc_files: | 
 |     filters.append(BuildCppTestFilter(cc_files, line)) | 
 |  | 
 |   return ':'.join(filters) | 
 |  | 
 |  | 
 | def main(): | 
 |   parser = argparse.ArgumentParser( | 
 |       description=__doc__, formatter_class=argparse.RawTextHelpFormatter) | 
 |   parser.add_argument('--out-dir', | 
 |                       '-C', | 
 |                       metavar='OUT_DIR', | 
 |                       help='output directory of the build') | 
 |   parser.add_argument( | 
 |       '--run-all', | 
 |       action='store_true', | 
 |       help='Run all tests for the file or directory, instead of just one') | 
 |   parser.add_argument('--line', | 
 |                       type=int, | 
 |                       help='run only the test on this line number. c++ only.') | 
 |   parser.add_argument( | 
 |       '--gtest_filter', '-f', metavar='FILTER', help='test filter') | 
 |   parser.add_argument( | 
 |       '--dry-run', | 
 |       '-n', | 
 |       action='store_true', | 
 |       help='Print ninja and test run commands without executing them.') | 
 |   parser.add_argument('file', | 
 |                       metavar='FILE_NAME', | 
 |                       help='test suite file (eg. FooTest.java)') | 
 |  | 
 |   args, _extras = parser.parse_known_args() | 
 |  | 
 |   # Use CWD as out_dir when build.ninja exists. | 
 |   if not args.out_dir and os.path.exists('build.ninja'): | 
 |     args.out_dir = '.' | 
 |  | 
 |   if not os.path.isdir(args.out_dir): | 
 |     parser.error(f'OUT_DIR "{args.out_dir}" does not exist.') | 
 |   target_cache = TargetCache(args.out_dir) | 
 |   filenames = FindMatchingTestFiles(args.file) | 
 |  | 
 |   targets, used_cache = FindTestTargets(target_cache, args.out_dir, filenames, | 
 |                                         args.run_all) | 
 |  | 
 |   gtest_filter = args.gtest_filter | 
 |   if not gtest_filter: | 
 |     gtest_filter = BuildTestFilter(filenames, args.line) | 
 |  | 
 |   if not gtest_filter: | 
 |     ExitWithMessage('Failed to derive a gtest filter') | 
 |  | 
 |   assert targets | 
 |   build_ok = BuildTestTargetsWithNinja(args.out_dir, targets, args.dry_run) | 
 |  | 
 |   # If we used the target cache, it's possible we chose the wrong target because | 
 |   # a gn file was changed. The build step above will check for gn modifications | 
 |   # and update build.ninja. Use this opportunity the verify the cache is still | 
 |   # valid. | 
 |   if used_cache and not target_cache.IsStillValid(): | 
 |     target_cache = TargetCache(args.out_dir) | 
 |     new_targets, _ = FindTestTargets(target_cache, args.out_dir, filenames, | 
 |                                      args.run_all) | 
 |     if targets != new_targets: | 
 |       # Note that this can happen, for example, if you rename a test target. | 
 |       print('gn config was changed, trying to build again', file=sys.stderr) | 
 |       targets = new_targets | 
 |       if not BuildTestTargetsWithNinja(args.out_dir, targets, args.dry_run): | 
 |         sys.exit(1) | 
 |   else:  # cache still valid, quit if the build failed | 
 |     if not build_ok: sys.exit(1) | 
 |  | 
 |   RunTestTargets(args.out_dir, targets, gtest_filter, _extras, args.dry_run) | 
 |  | 
 |  | 
 | if __name__ == '__main__': | 
 |   sys.exit(main()) |