
- Use make-gtest-filter.py to build a gtest filter automatically. - Allow testing a full directory. - --run-all: allow testing multiple targets in one run, instead of asking the user to pick. - Speed it up if a file path is specified, by skipping the file search. - Cache gn refs output, to make follow-up runs faster. - Add --line option, to run a test at specific line number. - Change call to ninja build so that output is streamed - Switch to printing errors end then exiting for non-exceptional errors. This makes user errors more readable. Change-Id: Ib3660123f5a7fe5c3ef3369d360c3ea3218d233a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2391209 Commit-Queue: Dan H <harringtond@chromium.org> Reviewed-by: Michael Thiessen <mthiesse@chromium.org> Reviewed-by: Andrew Grieve <agrieve@chromium.org> Cr-Commit-Position: refs/heads/master@{#804987}
376 lines
12 KiB
Python
Executable File
376 lines
12 KiB
Python
Executable File
#!/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"""
|
|
ninja_path = os.path.join(DEPOT_TOOLS_DIR, 'autoninja')
|
|
if sys.platform.startswith('win32'):
|
|
ninja_path += '.bat'
|
|
cmd = [ninja_path, '-C', out_dir] + targets
|
|
print('Building: ' + ' '.join(cmd))
|
|
if (dry_run):
|
|
return
|
|
StreamCommandOrExit(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 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):
|
|
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.path = os.path.join(out_dir, 'autotest_cache')
|
|
self.gold_mtime = os.path.getmtime(os.path.join(out_dir, 'build.ninja'))
|
|
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 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)
|
|
if not test_targets:
|
|
|
|
# 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
|
|
|
|
|
|
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',
|
|
required=True)
|
|
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()
|
|
|
|
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 = 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
|
|
BuildTestTargetsWithNinja(args.out_dir, targets, args.dry_run)
|
|
RunTestTargets(args.out_dir, targets, gtest_filter, _extras, args.dry_run)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|