#!/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 suite for the specified test file, builds it, then runs it with the (optionally) specified filter, passing any extra args on to the test runner. Examples: autotest.py -C out/Desktop bit_cast_unittest.cc --gtest_filter=BitCastTest* -v autotest.py -C out/Android UrlUtilitiesUnitTest --fast-local-dev -v """ import argparse import locale import logging import multiprocessing import os import re import shlex 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', ] class CommandError(Exception): """Exception thrown when we can't parse the input file.""" 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 LogCommand(cmd, **kwargs): if DEBUG: print('Running command: ' + ' '.join(cmd)) try: subprocess.check_call(cmd, **kwargs) except subprocess.CalledProcessError as e: raise CommandError(e.cmd, e.returncode) from None def RunCommand(cmd, **kwargs): if DEBUG: print('Running command: ' + ' '.join(cmd)) 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 BuildTestTargetWithNinja(out_dir, target, dry_run): """Builds the specified target with ninja""" ninja_path = os.path.join(DEPOT_TOOLS_DIR, 'autoninja') if sys.platform.startswith('win32'): ninja_path += '.bat' cmd = [ninja_path, '-C', out_dir, target] print('Building: ' + ' '.join(cmd)) if (dry_run): return RunCommand(cmd) 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('.')): 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 FindMatchingTestFile(target): 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] raise Exception(f'Target "{target}" is ambiguous. Matching files: ' f'{results}') if not results: raise Exception(f'Target "{target}" did not match any files.') return results[0] def IsTestTarget(target): for suffix in _TEST_TARGET_SUFFIXES: if target.endswith(suffix): return True return target in _OTHER_TEST_TARGETS def HaveUserPickTarget(path, targets): # Cap to 10 targets for convenience [0-9]. targets = targets[:10] target_list = '' i = 0 for target in targets: target_list += f'{i}. {target}\n' i += 1 try: value = int( input(f'Target "{path}" is used by multiple test targets.\n' + target_list + 'Please pick a target: ')) return targets[value] except Exception as e: print('Try again') return HaveUserPickTarget(path, targets) def FindTestTarget(out_dir, path): # 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', path] targets = RunCommand(cmd, cwd=SRC_DIR).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: raise Exception( f'Target "{path}" did not match any test targets. Consider adding ' f'one of the following targets to the top of this file: {targets}') target = test_targets[0] if len(test_targets) > 1: target = HaveUserPickTarget(path, test_targets) return target.split(':')[-1] def RunTestTarget(out_dir, target, gtest_filter, extra_args, dry_run): # 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) extra_args = ' '.join(extra_args) cmd = [path, f'--gtest_filter={gtest_filter}'] + shlex.split(extra_args) print('Running test: ' + ' '.join(cmd)) if (dry_run): return LogCommand(cmd) 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', required=True) 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() if not os.path.isdir(args.out_dir): parser.error(f'OUT_DIR "{args.out_dir}" does not exist.') filename = FindMatchingTestFile(args.file) gtest_filter = args.gtest_filter if not gtest_filter: if not filename.endswith('java'): # In c++ tests, the test class often doesn't match the filename, or a # single file will contain multiple test classes. It's likely possible to # handle most cases with a regex and provide a default here. # Patches welcome :) parser.error('--gtest_filter must be specified for non-java tests.') gtest_filter = '*' + os.path.splitext(os.path.basename(filename))[0] + '*' target = FindTestTarget(args.out_dir, filename) BuildTestTargetWithNinja(args.out_dir, target, args.dry_run) RunTestTarget(args.out_dir, target, gtest_filter, _extras, args.dry_run) if __name__ == '__main__': sys.exit(main())