1# Copyright 2023, The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15import logging 16import os 17import re 18 19 20# Gtest Types 21GTEST_REGULAR = 'regular native test' 22GTEST_TYPED = 'typed test' 23GTEST_TYPED_PARAM = 'typed-parameterized test' 24GTEST_PARAM = 'value-parameterized test' 25 26 27# Macros that used in GTest. Detailed explanation can be found in 28# $ANDROID_BUILD_TOP/external/googletest/googletest/samples/sample*_unittest.cc 29# 1. Traditional Tests: 30# TEST(class, method) 31# TEST_F(class, method) 32# 2. Type Tests: 33# TYPED_TEST_SUITE(class, types) 34# TYPED_TEST(class, method) 35# 3. Value-parameterized Tests: 36# TEST_P(class, method) 37# INSTANTIATE_TEST_SUITE_P(Prefix, class, param_generator, name_generator) 38# 4. Type-parameterized Tests: 39# TYPED_TEST_SUITE_P(class) 40# TYPED_TEST_P(class, method) 41# REGISTER_TYPED_TEST_SUITE_P(class, method) 42# INSTANTIATE_TYPED_TEST_SUITE_P(Prefix, class, Types) 43# Macros with (class, method) pattern. 44CC_CLASS_METHOD_RE = re.compile( 45 r'^\s*(TYPED_TEST(?:|_P)|TEST(?:|_F|_P))\s*\(\s*' 46 r'(?P<class_name>\w+),\s*(?P<method_name>\w+)\)\s*\{', 47 re.M, 48) 49# Macros that used in GTest with flags. Detailed example can be found in 50# $ANDROID_BUILD_TOP/cts/flags/cc_tests/src/FlagMacrosTests.cpp 51# Macros with (prefix, class, ...) pattern. 52CC_FLAG_CLASS_METHOD_RE = re.compile( 53 r'^\s*(TEST(?:|_F))_WITH_FLAGS\s*\(\s*' 54 r'(?P<class_name>\w+),\s*(?P<method_name>\w+),', 55 re.M, 56) 57# Macros with (prefix, class, ...) pattern. 58# Note: Since v1.08, the INSTANTIATE_TEST_CASE_P was replaced with 59# INSTANTIATE_TEST_SUITE_P. However, Atest does not intend to change the 60# behavior of a test, so we still search *_CASE_* macros. 61CC_PARAM_CLASS_RE = re.compile( 62 r'^\s*INSTANTIATE_(?:|TYPED_)TEST_(?:SUITE|CASE)_P\s*\(\s*' 63 r'(?P<instantiate>\w+),\s*(?P<class>\w+)\s*,', 64 re.M, 65) 66# Type/Type-parameterized Test macros: 67TYPE_CC_CLASS_RE = re.compile( 68 r'^\s*TYPED_TEST_SUITE(?:|_P)\(\s*(?P<class_name>\w+)', re.M 69) 70 71# RE for suspected parameterized java/kt class. 72_SUSPECTED_PARAM_CLASS_RE = re.compile( 73 r'^\s*@RunWith\s*\(\s*(TestParameterInjector|' 74 r'JUnitParamsRunner|DataProviderRunner|JukitoRunner|Theories|BedsteadJUnit4' 75 r')(\.|::)class\s*\)', 76 re.I, 77) 78# Parse package name from the package declaration line of a java or 79# a kotlin file. 80# Group matches "foo.bar" of line "package foo.bar;" or "package foo.bar" 81_PACKAGE_RE = re.compile(r'\s*package\s+(?P<package>[^(;|\s)]+)\s*', re.I) 82 83 84class TooManyMethodsError(Exception): 85 """Raised when input string contains more than one # character.""" 86 87 88class MoreThanOneClassError(Exception): 89 """Raised when multiple classes given in 'classA,classB' pattern.""" 90 91 92class MissingPackageNameError(Exception): 93 """Raised when the test class java file does not contain a package name.""" 94 95 96def get_cc_class_info(class_file_content): 97 """Get the class info of the given cc class file content. 98 99 The class info dict will be like: 100 {'classA': { 101 'methods': {'m1', 'm2'}, 'prefixes': {'pfx1'}, 'typed': True}, 102 'classB': { 103 'methods': {'m3', 'm4'}, 'prefixes': set(), 'typed': False}, 104 'classC': { 105 'methods': {'m5', 'm6'}, 'prefixes': set(), 'typed': True}, 106 'classD': { 107 'methods': {'m7', 'm8'}, 'prefixes': {'pfx3'}, 'typed': False}} 108 According to the class info, we can tell that: 109 classA is a typed-parameterized test. (TYPED_TEST_SUITE_P) 110 classB is a regular gtest. (TEST_F|TEST) 111 classC is a typed test. (TYPED_TEST_SUITE) 112 classD is a value-parameterized test. (TEST_P) 113 114 Args: 115 class_file_content: Content of the cc class file. 116 117 Returns: 118 A tuple of a dict of class info and a list of classes that have no test. 119 """ 120 flag_method_matches = re.findall(CC_FLAG_CLASS_METHOD_RE, class_file_content) 121 # ('TYPED_TEST', 'PrimeTableTest', 'ReturnsTrueForPrimes') 122 method_matches = re.findall(CC_CLASS_METHOD_RE, class_file_content) 123 # ('OnTheFlyAndPreCalculated', 'PrimeTableTest2') 124 prefix_matches = re.findall(CC_PARAM_CLASS_RE, class_file_content) 125 # 'PrimeTableTest' 126 typed_matches = re.findall(TYPE_CC_CLASS_RE, class_file_content) 127 128 classes = {cls[1] for cls in method_matches + flag_method_matches} 129 class_info = {} 130 for cls in classes: 131 class_info.setdefault( 132 cls, {'methods': set(), 'prefixes': set(), 'typed': False} 133 ) 134 135 no_test_classes = [] 136 137 logging.debug('Probing TestCase.TestName pattern:') 138 for match in method_matches + flag_method_matches: 139 if class_info.get(match[1]): 140 logging.debug(' Found %s.%s', match[1], match[2]) 141 class_info[match[1]]['methods'].add(match[2]) 142 else: 143 no_test_classes.append(match[1]) 144 145 # Parameterized test. 146 logging.debug('Probing InstantiationName/TestCase pattern:') 147 for match in prefix_matches: 148 if class_info.get(match[1]): 149 logging.debug(' Found %s/%s', match[0], match[1]) 150 class_info[match[1]]['prefixes'].add(match[0]) 151 else: 152 no_test_classes.append(match[1]) 153 154 # Typed test 155 logging.debug('Probing typed test names:') 156 for match in typed_matches: 157 if class_info.get(match): 158 logging.debug(' Found %s', match) 159 class_info[match]['typed'] = True 160 else: 161 no_test_classes.append(match[1]) 162 163 return class_info, no_test_classes 164 165 166def get_cc_class_type(class_info, classname): 167 """Tell the type of the given class. 168 169 Args: 170 class_info: A dict of class info. 171 classname: A string of class name. 172 173 Returns: 174 String of the gtest type to prompt. The output will be one of: 175 1. 'regular test' (GTEST_REGULAR) 176 2. 'typed test' (GTEST_TYPED) 177 3. 'value-parameterized test' (GTEST_PARAM) 178 4. 'typed-parameterized test' (GTEST_TYPED_PARAM) 179 """ 180 if class_info.get(classname).get('prefixes'): 181 if class_info.get(classname).get('typed'): 182 return GTEST_TYPED_PARAM 183 return GTEST_PARAM 184 if class_info.get(classname).get('typed'): 185 return GTEST_TYPED 186 return GTEST_REGULAR 187 188 189def get_cc_filter(class_info, class_name, methods): 190 """Get the cc filter. 191 192 Args: 193 class_info: a dict of class info. 194 class_name: class name of the cc test. 195 methods: a list of method names. 196 197 Returns: 198 A formatted string for cc filter. 199 For a Type/Typed-parameterized test, it will be: 200 "class1/*.method1:class1/*.method2" or "class1/*.*" 201 For a parameterized test, it will be: 202 "*/class1.*" or "prefix/class1.*" 203 For the rest the pattern will be: 204 "class1.method1:class1.method2" or "class1.*" 205 """ 206 # Strip prefix from class_name. 207 _class_name = class_name 208 if '/' in class_name: 209 _class_name = str(class_name).split('/')[-1] 210 type_str = get_cc_class_type(class_info, _class_name) 211 logging.debug('%s is a "%s".', _class_name, type_str) 212 # When found parameterized tests, recompose the class name 213 # in */$(ClassName) if the prefix is not given. 214 if type_str in (GTEST_TYPED_PARAM, GTEST_PARAM): 215 if not '/' in class_name: 216 class_name = '*/%s' % class_name 217 if type_str in (GTEST_TYPED, GTEST_TYPED_PARAM): 218 if methods: 219 sorted_methods = sorted(list(methods)) 220 return ':'.join(['%s/*.%s' % (class_name, x) for x in sorted_methods]) 221 return '%s/*.*' % class_name 222 if methods: 223 sorted_methods = sorted(list(methods)) 224 return ':'.join(['%s.%s' % (class_name, x) for x in sorted_methods]) 225 return '%s.*' % class_name 226 227 228def is_parameterized_java_class(test_path): 229 """Find out if input test path is a parameterized java class. 230 231 Args: 232 test_path: A string of absolute path to the java file. 233 234 Returns: 235 Boolean: Is parameterized class or not. 236 """ 237 with open(test_path) as class_file: 238 for line in class_file: 239 # Return immediately if the @ParameterizedTest annotation is found. 240 if re.compile(r'\s*@ParameterizedTest').match(line): 241 return True 242 # Return when Parameterized.class is invoked in @RunWith annotation. 243 # @RunWith(Parameterized.class) -> Java. 244 # @RunWith(Parameterized::class) -> kotlin. 245 if re.compile(r'^\s*@RunWith\s*\(\s*Parameterized.*(\.|::)class').match( 246 line 247 ): 248 return True 249 if _SUSPECTED_PARAM_CLASS_RE.match(line): 250 return True 251 return False 252 253 254def get_java_method_filters(class_file, methods): 255 """Get a frozenset of method filter when the given is a Java class. 256 257 class_file: The Java/kt file path. 258 methods: a set of method string. 259 260 Returns: 261 Frozenset of methods. 262 """ 263 method_filters = methods 264 if is_parameterized_java_class(class_file): 265 update_methods = [] 266 for method in methods: 267 # Only append * to the method if brackets are not a part of 268 # the method name, and result in running all parameters of 269 # the parameterized test. 270 if not _contains_brackets(method, pair=False): 271 update_methods.append(method + '*') 272 else: 273 update_methods.append(method) 274 method_filters = frozenset(update_methods) 275 276 return method_filters 277 278 279def split_methods(user_input): 280 """Split user input string into test reference and list of methods. 281 282 Args: 283 user_input: A string of the user's input. 284 Examples: class_name class_name#method1,method2 path 285 path#method1,method2 286 287 Returns: 288 A tuple. First element is String of test ref and second element is 289 a set of method name strings or empty list if no methods included. 290 Exception: 291 atest_error.TooManyMethodsError raised when input string is trying to 292 specify too many methods in a single positional argument. 293 294 Examples of unsupported input strings: 295 module:class#method,class#method 296 class1#method,class2#method 297 path1#method,path2#method 298 """ 299 error_msg = ( 300 'Too many "{}" characters in user input:\n\t{}\n' 301 'Multiple classes should be separated by space, and methods belong to ' 302 'the same class should be separated by comma. Example syntaxes are:\n' 303 '\tclass1 class2#method1 class3#method2,method3\n' 304 '\tclass1#method class2#method' 305 ) 306 if not '#' in user_input: 307 if ',' in user_input: 308 raise MoreThanOneClassError(error_msg.format(',', user_input)) 309 return user_input, frozenset() 310 parts = user_input.split('#') 311 if len(parts) > 2: 312 raise TooManyMethodsError(error_msg.format('#', user_input)) 313 # (b/260183137) Support parsing multiple parameters. 314 parsed_methods = [] 315 brackets = ('[', ']') 316 for part in parts[1].split(','): 317 count = {part.count(p) for p in brackets} 318 # If brackets are in pair, the length of count should be 1. 319 if len(count) == 1: 320 parsed_methods.append(part) 321 else: 322 # The front part of the pair, e.g. 'method[1' 323 if re.compile(r'^[a-zA-Z0-9]+\[').match(part): 324 parsed_methods.append(part) 325 continue 326 # The rear part of the pair, e.g. '5]]', accumulate this part to 327 # the last index of parsed_method. 328 parsed_methods[-1] += f',{part}' 329 return parts[0], frozenset(parsed_methods) 330 331 332def _contains_brackets(string: str, pair: bool = True) -> bool: 333 """Determines whether a given string contains (pairs of) brackets. 334 335 Args: 336 string: The string to check for brackets. 337 pair: Whether to check for brackets in pairs. 338 339 Returns: 340 bool: True if the given contains full pair of brackets; False otherwise. 341 """ 342 if not pair: 343 return re.search(r'\(|\)|\[|\]|\{|\}', string) 344 345 stack = [] 346 brackets = {'(': ')', '[': ']', '{': '}'} 347 for char in string: 348 if char in brackets: 349 stack.append(char) 350 elif char in brackets.values(): 351 if not stack or brackets[stack.pop()] != char: 352 return False 353 return len(stack) == 0 354 355 356def get_package_name(file_path): 357 """Parse the package name from a java file. 358 359 Args: 360 file_path: A string of the absolute path to the java file. 361 362 Returns: 363 A string of the package name or None 364 """ 365 with open(file_path) as data: 366 for line in data: 367 match = _PACKAGE_RE.match(line) 368 if match: 369 return match.group('package') 370 371 372# pylint: disable=inconsistent-return-statements 373def get_fully_qualified_class_name(test_path): 374 """Parse the fully qualified name from the class java file. 375 376 Args: 377 test_path: A string of absolute path to the java class file. 378 379 Returns: 380 A string of the fully qualified class name. 381 382 Raises: 383 atest_error.MissingPackageName if no class name can be found. 384 """ 385 package = get_package_name(test_path) 386 if package: 387 cls = os.path.splitext(os.path.split(test_path)[1])[0] 388 return '%s.%s' % (package, cls) 389 raise MissingPackageNameError( 390 f'{test_path}: Test class java file does not contain a package name.' 391 ) 392