| #!/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 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)) |
| |
| # Note: Test names have the following structures: |
| # * FixtureName.TestName |
| # * InstantiationName/FixtureName.TestName/## |
| # Since this script doesn't parse instantiations, we generate filters to |
| # match either regular tests or instantiated tests. |
| if args.wildcard_compress: |
| test_filters = CompressWithWildcards(tests, args.wildcard_min_depth, |
| args.wildcard_min_cases) |
| elif args.class_only: |
| fixtures = set([t.split('.')[0] for t in tests]) |
| test_filters = [c + '.*' for c in fixtures] + \ |
| ['*/' + c + '.*/*' for c in fixtures] |
| else: |
| test_filters = [c for c in tests] + \ |
| ['*/' + c + '/*' for c in tests] |
| |
| 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()) |