• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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