Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 1 | #!/usr/bin/env python |
Avi Drissman | dfd88085 | 2022-09-15 20:11:09 | [diff] [blame] | 2 | # Copyright 2018 The Chromium Authors |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | """Reads lines from files or stdin and identifies C++ tests. |
| 6 | |
| 7 | Outputs a filter that can be used with --gtest_filter or a filter file to |
| 8 | run only the tests identified. |
| 9 | |
| 10 | Usage: |
| 11 | |
| 12 | Outputs filter for all test fixtures in a directory. --class-only avoids an |
| 13 | overly long filter string. |
Roman Sorokin | 34f5e2a | 2022-02-02 16:31:27 | [diff] [blame] | 14 | $ cat components/mycomp/**test.cc | make_gtest_filter.py --class-only |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 15 | |
| 16 | Outputs filter for all tests in a file. |
Roman Sorokin | 34f5e2a | 2022-02-02 16:31:27 | [diff] [blame] | 17 | $ make_gtest_filter.py ./myfile_unittest.cc |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 18 | |
| 19 | Outputs filter for only test at line 123 |
Roman Sorokin | 34f5e2a | 2022-02-02 16:31:27 | [diff] [blame] | 20 | $ make_gtest_filter.py --line=123 ./myfile_unittest.cc |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 21 | |
| 22 | Formats output as a GTest filter file. |
Roman Sorokin | 34f5e2a | 2022-02-02 16:31:27 | [diff] [blame] | 23 | $ make_gtest_filter.py ./myfile_unittest.cc --as-filter-file |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 24 | |
| 25 | Use a JSON failure summary as the input. |
Roman Sorokin | 34f5e2a | 2022-02-02 16:31:27 | [diff] [blame] | 26 | $ make_gtest_filter.py summary.json --from-failure-summary |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 27 | |
| 28 | Elide the filter list using wildcards when possible. |
Roman Sorokin | 34f5e2a | 2022-02-02 16:31:27 | [diff] [blame] | 29 | $ make_gtest_filter.py summary.json --from-failure-summary --wildcard-compress |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 30 | """ |
| 31 | |
| 32 | from __future__ import print_function |
| 33 | |
| 34 | import argparse |
| 35 | import collections |
| 36 | import fileinput |
| 37 | import json |
| 38 | import re |
| 39 | import sys |
| 40 | |
| 41 | |
| 42 | class TrieNode: |
| 43 | def __init__(self): |
| 44 | # The number of strings which terminated on or underneath this node. |
| 45 | self.num_strings = 0 |
| 46 | |
| 47 | # The prefix subtries which follow |this|, keyed by their next character. |
| 48 | self.children = {} |
| 49 | |
| 50 | |
| 51 | def PascalCaseSplit(input_string): |
| 52 | current_term = [] |
| 53 | prev_char = '' |
| 54 | |
| 55 | for current_char in input_string: |
| 56 | is_boundary = prev_char != '' and \ |
| 57 | ((current_char.isupper() and prev_char.islower()) or \ |
| 58 | (current_char.isalpha() != prev_char.isalpha()) or \ |
| 59 | (current_char.isalnum() != prev_char.isalnum())) |
| 60 | prev_char = current_char |
| 61 | |
| 62 | if is_boundary: |
| 63 | yield ''.join(current_term) |
| 64 | current_term = [] |
| 65 | |
| 66 | current_term.append(current_char) |
| 67 | |
| 68 | if len(current_term) > 0: |
| 69 | yield ''.join(current_term) |
| 70 | |
| 71 | |
| 72 | def TrieInsert(trie, value): |
| 73 | """Inserts the characters of 'value' into a trie, with every edge representing |
| 74 | a single character. An empty child set indicates end-of-string.""" |
| 75 | |
| 76 | for term in PascalCaseSplit(value): |
| 77 | trie.num_strings = trie.num_strings + 1 |
| 78 | if term in trie.children: |
| 79 | trie = trie.children[term] |
| 80 | else: |
| 81 | subtrie = TrieNode() |
| 82 | trie.children[term] = subtrie |
| 83 | trie = subtrie |
| 84 | |
| 85 | trie.num_strings = trie.num_strings + 1 |
| 86 | |
| 87 | |
| 88 | def ComputeWildcardsFromTrie(trie, min_depth, min_cases): |
| 89 | """Computes a list of wildcarded test case names from a trie using a depth |
| 90 | first traversal.""" |
| 91 | |
| 92 | WILDCARD = '*' |
| 93 | |
| 94 | # Stack of values to process, initialized with the root node. |
| 95 | # The first item of the tuple is the substring represented by the traversal so |
| 96 | # far. |
| 97 | # The second item of the tuple is the TrieNode itself. |
| 98 | # The third item is the depth of the traversal so far. |
| 99 | to_process = [('', trie, 0)] |
| 100 | |
| 101 | while len(to_process) > 0: |
| 102 | cur_prefix, cur_trie, cur_depth = to_process.pop() |
| 103 | assert (cur_trie.num_strings != 0) |
| 104 | |
| 105 | if len(cur_trie.children) == 0: |
| 106 | # No more children == we're at the end of a string. |
| 107 | yield cur_prefix |
| 108 | |
| 109 | elif (cur_depth == min_depth) and \ |
| 110 | cur_trie.num_strings > min_cases: |
| 111 | # Trim traversal of this path if the path is deep enough and there |
| 112 | # are enough entries to warrant elision. |
| 113 | yield cur_prefix + WILDCARD |
| 114 | |
| 115 | else: |
| 116 | # Traverse all children of this node. |
| 117 | for term, subtrie in cur_trie.children.items(): |
| 118 | to_process.append((cur_prefix + term, subtrie, cur_depth + 1)) |
| 119 | |
| 120 | |
| 121 | def CompressWithWildcards(test_list, min_depth, min_cases): |
| 122 | """Given a list of SUITE.CASE names, generates an exclusion list using |
| 123 | wildcards to reduce redundancy. |
| 124 | For example: |
| 125 | Foo.TestOne |
| 126 | Foo.TestTwo |
| 127 | becomes: |
| 128 | Foo.Test*""" |
| 129 | |
| 130 | suite_tries = {} |
| 131 | |
| 132 | # First build up a trie based representations of all test case names, |
| 133 | # partitioned per-suite. |
| 134 | for case in test_list: |
| 135 | suite_name, test = case.split('.') |
| 136 | if not suite_name in suite_tries: |
| 137 | suite_tries[suite_name] = TrieNode() |
| 138 | TrieInsert(suite_tries[suite_name], test) |
| 139 | |
| 140 | output = [] |
| 141 | # Go through the suites' tries and generate wildcarded representations |
| 142 | # of the cases. |
| 143 | for suite in suite_tries.items(): |
| 144 | suite_name, cases_trie = suite |
| 145 | for case_wildcard in ComputeWildcardsFromTrie(cases_trie, min_depth, \ |
| 146 | min_cases): |
| 147 | output.append("{}.{}".format(suite_name, case_wildcard)) |
| 148 | |
| 149 | output.sort() |
| 150 | return output |
| 151 | |
| 152 | |
| 153 | def GetFailedTestsFromTestLauncherSummary(summary): |
| 154 | failures = set() |
| 155 | for iteration in summary['per_iteration_data']: |
| 156 | for case_name, results in iteration.items(): |
| 157 | for result in results: |
| 158 | if result['status'] == 'FAILURE': |
| 159 | failures.add(case_name) |
| 160 | return list(failures) |
| 161 | |
| 162 | |
James Lee | 77f3c7ff | 2023-04-13 15:06:26 | [diff] [blame] | 163 | def GetFiltersForTests(tests, class_only): |
| 164 | # Note: Test names have the following structures: |
| 165 | # * FixtureName.TestName |
James Lee | 2e9a30b | 2023-04-13 15:51:38 | [diff] [blame] | 166 | # * InstantiationName/FixtureName.TestName/## (for TEST_P) |
Tomasz Jurkiewicz | 0c853c7 | 2023-07-27 08:11:17 | [diff] [blame] | 167 | # * InstantiationName/FixtureName/ParameterId.TestName (for TYPED_TEST_P) |
James Lee | 77f3c7ff | 2023-04-13 15:06:26 | [diff] [blame] | 168 | # * FixtureName.TestName/## |
James Lee | 2e9a30b | 2023-04-13 15:51:38 | [diff] [blame] | 169 | # * FixtureName/##.TestName (for TYPED_TEST) |
James Lee | 77f3c7ff | 2023-04-13 15:06:26 | [diff] [blame] | 170 | # Since this script doesn't parse instantiations, we generate filters to |
| 171 | # match either regular tests or instantiated tests. |
| 172 | if class_only: |
| 173 | fixtures = set([t.split('.')[0] for t in tests]) |
| 174 | return [c + '.*' for c in fixtures] + \ |
Ming-Ying Chung | 2da9f25 | 2023-07-24 04:26:15 | [diff] [blame] | 175 | ['*/' + c + '.*/*' for c in fixtures] + \ |
Tomasz Jurkiewicz | 0c853c7 | 2023-07-27 08:11:17 | [diff] [blame] | 176 | ['*/' + c + '/*.*' for c in fixtures] + \ |
James Lee | 2e9a30b | 2023-04-13 15:51:38 | [diff] [blame] | 177 | [c + '.*/*' for c in fixtures] + \ |
| 178 | [c + '/*.*' for c in fixtures] |
James Lee | 77f3c7ff | 2023-04-13 15:06:26 | [diff] [blame] | 179 | else: |
James Lee | 2e9a30b | 2023-04-13 15:51:38 | [diff] [blame] | 180 | fixtures_and_tcs = [test.split('.', 1) for test in tests] |
James Lee | 77f3c7ff | 2023-04-13 15:06:26 | [diff] [blame] | 181 | return [c for c in tests] + \ |
| 182 | ['*/' + c + '/*' for c in tests] + \ |
James Lee | 2e9a30b | 2023-04-13 15:51:38 | [diff] [blame] | 183 | [c + '/*' for c in tests] + \ |
| 184 | [fixture + '/*.' + tc for fixture, tc in fixtures_and_tcs] |
James Lee | 77f3c7ff | 2023-04-13 15:06:26 | [diff] [blame] | 185 | |
| 186 | |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 187 | def main(): |
| 188 | parser = argparse.ArgumentParser() |
| 189 | parser.add_argument( |
| 190 | '--input-format', |
| 191 | choices=['swarming_summary', 'test_launcher_summary', 'test_file'], |
| 192 | default='test_file') |
| 193 | parser.add_argument('--output-format', |
| 194 | choices=['file', 'args'], |
| 195 | default='args') |
| 196 | parser.add_argument('--wildcard-compress', action='store_true') |
| 197 | parser.add_argument( |
| 198 | '--wildcard-min-depth', |
Bryant Chandler | 3b4a137 | 2022-03-14 23:09:35 | [diff] [blame] | 199 | type=int, |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 200 | default=1, |
| 201 | help="Minimum number of terms in a case before a wildcard may be " + |
| 202 | "used, so that prefixes are not excessively broad.") |
| 203 | parser.add_argument( |
| 204 | '--wildcard-min-cases', |
Bryant Chandler | 3b4a137 | 2022-03-14 23:09:35 | [diff] [blame] | 205 | type=int, |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 206 | default=3, |
| 207 | help="Minimum number of cases in a filter before folding into a " + |
| 208 | "wildcard, so as to not create wildcards needlessly for small " |
| 209 | "numbers of similarly named test failures.") |
| 210 | parser.add_argument('--line', type=int) |
| 211 | parser.add_argument('--class-only', action='store_true') |
| 212 | parser.add_argument( |
| 213 | '--as-exclusions', |
| 214 | action='store_true', |
| 215 | help='Generate exclusion rules for test cases, instead of inclusions.') |
| 216 | args, left = parser.parse_known_args() |
| 217 | |
| 218 | test_filters = [] |
| 219 | if args.input_format == 'swarming_summary': |
| 220 | # Decode the JSON files separately and combine their contents. |
| 221 | test_filters = [] |
| 222 | for json_file in left: |
| 223 | test_filters.extend(json.loads('\n'.join(open(json_file, 'r')))) |
| 224 | |
| 225 | if args.wildcard_compress: |
| 226 | test_filters = CompressWithWildcards(test_filters, |
| 227 | args.wildcard_min_depth, |
| 228 | args.wildcard_min_cases) |
| 229 | |
| 230 | elif args.input_format == 'test_launcher_summary': |
| 231 | # Decode the JSON files separately and combine their contents. |
| 232 | test_filters = [] |
| 233 | for json_file in left: |
| 234 | test_filters.extend( |
| 235 | GetFailedTestsFromTestLauncherSummary( |
| 236 | json.loads('\n'.join(open(json_file, 'r'))))) |
| 237 | |
| 238 | if args.wildcard_compress: |
| 239 | test_filters = CompressWithWildcards(test_filters, |
| 240 | args.wildcard_min_depth, |
| 241 | args.wildcard_min_cases) |
| 242 | |
| 243 | else: |
| 244 | file_input = fileinput.input(left) |
| 245 | if args.line: |
| 246 | # If --line is used, restrict text to a few lines around the requested |
| 247 | # line. |
| 248 | requested_line = args.line |
| 249 | selected_lines = [] |
| 250 | for line in file_input: |
| 251 | if (fileinput.lineno() >= requested_line |
| 252 | and fileinput.lineno() <= requested_line + 1): |
| 253 | selected_lines.append(line) |
| 254 | txt = ''.join(selected_lines) |
| 255 | else: |
| 256 | txt = ''.join(list(file_input)) |
| 257 | |
| 258 | # This regex is not exhaustive, and should be updated as needed. |
| 259 | rx = re.compile( |
| 260 | r'^(?:TYPED_)?(?:IN_PROC_BROWSER_)?TEST(_F|_P)?\(\s*(\w+)\s*' + \ |
| 261 | r',\s*(\w+)\s*\)', |
| 262 | flags=re.DOTALL | re.M) |
| 263 | tests = [] |
| 264 | for m in rx.finditer(txt): |
Dan Harrington | 86bb065 | 2025-08-19 17:04:24 | [diff] [blame] | 265 | # Try to include partially disabled tests. |
| 266 | fixture, test_name = m.group(2), m.group(3) |
| 267 | fixture = fixture.removeprefix('MAYBE_') |
| 268 | test_name = test_name.removeprefix('MAYBE_') |
| 269 | tests.append(fixture + '.' + test_name) |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 270 | |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 271 | if args.wildcard_compress: |
| 272 | test_filters = CompressWithWildcards(tests, args.wildcard_min_depth, |
| 273 | args.wildcard_min_cases) |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 274 | else: |
James Lee | 77f3c7ff | 2023-04-13 15:06:26 | [diff] [blame] | 275 | test_filters = GetFiltersForTests(tests, args.class_only) |
Kevin Marshall | ab4f810b | 2022-01-31 20:18:43 | [diff] [blame] | 276 | |
| 277 | if args.as_exclusions: |
| 278 | test_filters = ['-' + x for x in test_filters] |
| 279 | |
| 280 | if args.output_format == 'file': |
| 281 | print('\n'.join(test_filters)) |
| 282 | else: |
| 283 | print(':'.join(test_filters)) |
| 284 | |
| 285 | return 0 |
| 286 | |
| 287 | |
| 288 | if __name__ == '__main__': |
| 289 | sys.exit(main()) |