#!/usr/bin/env python
# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Reads lines from files or stdin and identifies C++ tests.
Outputs a filter that can be used with --gtest_filter or a filter file to
run only the tests identified.
Usage:
Outputs filter for all test fixtures in a directory. --class-only avoids an
overly long filter string.
$ cat components/mycomp/**test.cc | make_gtest_filter.py --class-only
Outputs filter for all tests in a file.
$ make_gtest_filter.py ./myfile_unittest.cc
Outputs filter for only test at line 123
$ make_gtest_filter.py --line=123 ./myfile_unittest.cc
Formats output as a GTest filter file.
$ make_gtest_filter.py ./myfile_unittest.cc --as-filter-file
Use a JSON failure summary as the input.
$ make_gtest_filter.py summary.json --from-failure-summary
Elide the filter list using wildcards when possible.
$ make_gtest_filter.py summary.json --from-failure-summary --wildcard-compress
"""
from __future__ import print_function
import argparse
import collections
import fileinput
import json
import re
import sys
class TrieNode:
def __init__(self):
# The number of strings which terminated on or underneath this node.
self.num_strings = 0
# The prefix subtries which follow |this|, keyed by their next character.
self.children = {}
def PascalCaseSplit(input_string):
current_term = []
prev_char = ''
for current_char in input_string:
is_boundary = prev_char != '' and \
((current_char.isupper() and prev_char.islower()) or \
(current_char.isalpha() != prev_char.isalpha()) or \
(current_char.isalnum() != prev_char.isalnum()))
prev_char = current_char
if is_boundary:
yield ''.join(current_term)
current_term = []
current_term.append(current_char)
if len(current_term) > 0:
yield ''.join(current_term)
def TrieInsert(trie, value):
"""Inserts the characters of 'value' into a trie, with every edge representing
a single character. An empty child set indicates end-of-string."""
for term in PascalCaseSplit(value):
trie.num_strings = trie.num_strings + 1
if term in trie.children:
trie = trie.children[term]
else:
subtrie = TrieNode()
trie.children[term] = subtrie
trie = subtrie
trie.num_strings = trie.num_strings + 1
def ComputeWildcardsFromTrie(trie, min_depth, min_cases):
"""Computes a list of wildcarded test case names from a trie using a depth
first traversal."""
WILDCARD = '*'
# Stack of values to process, initialized with the root node.
# The first item of the tuple is the substring represented by the traversal so
# far.
# The second item of the tuple is the TrieNode itself.
# The third item is the depth of the traversal so far.
to_process = [('', trie, 0)]
while len(to_process) > 0:
cur_prefix, cur_trie, cur_depth = to_process.pop()
assert (cur_trie.num_strings != 0)
if len(cur_trie.children) == 0:
# No more children == we're at the end of a string.
yield cur_prefix
elif (cur_depth == min_depth) and \
cur_trie.num_strings > min_cases:
# Trim traversal of this path if the path is deep enough and there
# are enough entries to warrant elision.
yield cur_prefix + WILDCARD
else:
# Traverse all children of this node.
for term, subtrie in cur_trie.children.items():
to_process.append((cur_prefix + term, subtrie, cur_depth + 1))
def CompressWithWildcards(test_list, min_depth, min_cases):
"""Given a list of SUITE.CASE names, generates an exclusion list using
wildcards to reduce redundancy.
For example:
Foo.TestOne
Foo.TestTwo
becomes:
Foo.Test*"""
suite_tries = {}
# First build up a trie based representations of all test case names,
# partitioned per-suite.
for case in test_list:
suite_name, test = case.split('.')
if not suite_name in suite_tries:
suite_tries[suite_name] = TrieNode()
TrieInsert(suite_tries[suite_name], test)
output = []
# Go through the suites' tries and generate wildcarded representations
# of the cases.
for suite in suite_tries.items():
suite_name, cases_trie = suite
for case_wildcard in ComputeWildcardsFromTrie(cases_trie, min_depth, \
min_cases):
output.append("{}.{}".format(suite_name, case_wildcard))
output.sort()
return output
def GetFailedTestsFromTestLauncherSummary(summary):
failures = set()
for iteration in summary['per_iteration_data']:
for case_name, results in iteration.items():
for result in results:
if result['status'] == 'FAILURE':
failures.add(case_name)
return list(failures)
def GetFiltersForTests(tests, class_only):
# Note: Test names have the following structures:
# * FixtureName.TestName
# * InstantiationName/FixtureName.TestName/## (for TEST_P)
# * InstantiationName/FixtureName/ParameterId.TestName (for TYPED_TEST_P)
# * FixtureName.TestName/##
# * FixtureName/##.TestName (for TYPED_TEST)
# Since this script doesn't parse instantiations, we generate filters to
# match either regular tests or instantiated tests.
if class_only:
fixtures = set([t.split('.')[0] for t in tests])
return [c + '.*' for c in fixtures] + \
['*/' + c + '.*/*' for c in fixtures] + \
['*/' + c + '/*.*' for c in fixtures] + \
[c + '.*/*' for c in fixtures] + \
[c + '/*.*' for c in fixtures]
else:
fixtures_and_tcs = [test.split('.', 1) for test in tests]
return [c for c in tests] + \
['*/' + c + '/*' for c in tests] + \
[c + '/*' for c in tests] + \
[fixture + '/*.' + tc for fixture, tc in fixtures_and_tcs]
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
'--input-format',
choices=['swarming_summary', 'test_launcher_summary', 'test_file'],
default='test_file')
parser.add_argument('--output-format',
choices=['file', 'args'],
default='args')
parser.add_argument('--wildcard-compress', action='store_true')
parser.add_argument(
'--wildcard-min-depth',
type=int,
default=1,
help="Minimum number of terms in a case before a wildcard may be " +
"used, so that prefixes are not excessively broad.")
parser.add_argument(
'--wildcard-min-cases',
type=int,
default=3,
help="Minimum number of cases in a filter before folding into a " +
"wildcard, so as to not create wildcards needlessly for small "
"numbers of similarly named test failures.")
parser.add_argument('--line', type=int)
parser.add_argument('--class-only', action='store_true')
parser.add_argument(
'--as-exclusions',
action='store_true',
help='Generate exclusion rules for test cases, instead of inclusions.')
args, left = parser.parse_known_args()
test_filters = []
if args.input_format == 'swarming_summary':
# Decode the JSON files separately and combine their contents.
test_filters = []
for json_file in left:
test_filters.extend(json.loads('\n'.join(open(json_file, 'r'))))
if args.wildcard_compress:
test_filters = CompressWithWildcards(test_filters,
args.wildcard_min_depth,
args.wildcard_min_cases)
elif args.input_format == 'test_launcher_summary':
# Decode the JSON files separately and combine their contents.
test_filters = []
for json_file in left:
test_filters.extend(
GetFailedTestsFromTestLauncherSummary(
json.loads('\n'.join(open(json_file, 'r')))))
if args.wildcard_compress:
test_filters = CompressWithWildcards(test_filters,
args.wildcard_min_depth,
args.wildcard_min_cases)
else:
file_input = fileinput.input(left)
if args.line:
# If --line is used, restrict text to a few lines around the requested
# line.
requested_line = args.line
selected_lines = []
for line in file_input:
if (fileinput.lineno() >= requested_line
and fileinput.lineno() <= requested_line + 1):
selected_lines.append(line)
txt = ''.join(selected_lines)
else:
txt = ''.join(list(file_input))
# This regex is not exhaustive, and should be updated as needed.
rx = re.compile(
r'^(?:TYPED_)?(?:IN_PROC_BROWSER_)?TEST(_F|_P)?\(\s*(\w+)\s*' + \
r',\s*(\w+)\s*\)',
flags=re.DOTALL | re.M)
tests = []
for m in rx.finditer(txt):
tests.append(m.group(2) + '.' + m.group(3))
if args.wildcard_compress:
test_filters = CompressWithWildcards(tests, args.wildcard_min_depth,
args.wildcard_min_cases)
else:
test_filters = GetFiltersForTests(tests, args.class_only)
if args.as_exclusions:
test_filters = ['-' + x for x in test_filters]
if args.output_format == 'file':
print('\n'.join(test_filters))
else:
print(':'.join(test_filters))
return 0
if __name__ == '__main__':
sys.exit(main())