[PDNCF] Python 3.12 compatibility
[platform/framework/web/chromium-efl.git] / tools / autotest.py
1 #!/usr/bin/env python3
2 # Copyright 2020 The Chromium Authors
3 # Use of this source code is governed by a BSD-style license that can be
4 # found in the LICENSE file.
5 """Builds and runs a test by filename.
6
7 This script finds the appropriate test suites for the specified test files or
8 directories, builds it, then runs it with the (optionally) specified filter,
9 passing any extra args on to the test runner.
10
11 Examples:
12 # Run the test target for bit_cast_unittest.cc. Use a custom test filter instead
13 # of the automatically generated one.
14 autotest.py -C out/Desktop bit_cast_unittest.cc --gtest_filter=BitCastTest*
15
16 # Find and run UrlUtilitiesUnitTest.java's tests, pass remaining parameters to
17 # the test binary.
18 autotest.py -C out/Android UrlUtilitiesUnitTest --fast-local-dev -v
19
20 # Run all tests under base/strings.
21 autotest.py -C out/foo --run-all base/strings
22
23 # Run tests in multiple files or directories.
24 autotest.py -C out/foo base/strings base/pickle_unittest.cc
25
26 # Run only the test on line 11. Useful when running autotest.py from your text
27 # editor.
28 autotest.py -C out/foo --line 11 base/strings/strcat_unittest.cc
29 """
30
31 import argparse
32 import locale
33 import os
34 import json
35 import re
36 import shlex
37 import subprocess
38 import sys
39
40 from enum import Enum
41 from pathlib import Path
42
43 USE_PYTHON_3 = f'This script will only run under python3.'
44
45 SRC_DIR = Path(__file__).parent.parent.resolve()
46 sys.path.append(str(SRC_DIR / 'build'))
47 import gn_helpers
48
49 sys.path.append(str(SRC_DIR / 'build' / 'android'))
50 from pylib import constants
51
52 DEPOT_TOOLS_DIR = SRC_DIR / 'third_party' / 'depot_tools'
53 DEBUG = False
54
55 # Some test suites use suffixes that would also match non-test-suite targets.
56 # Those test suites should be manually added here.
57 _TEST_TARGET_ALLOWLIST = [
58     # Running ash_pixeltests requires the --no-try-android-wrappers flag.
59     '//ash:ash_pixeltests',
60     '//chrome/test:browser_tests',
61     '//chrome/test:interactive_ui_tests',
62     '//chrome/test:unit_tests',
63 ]
64
65 _TEST_TARGET_REGEX = re.compile(r'(_browsertests|_perftests|_wpr_tests)$')
66
67 TEST_FILE_NAME_REGEX = re.compile(r'(.*Test\.java)|(.*_[a-z]*test\.cc)')
68
69 # Some tests don't directly include gtest.h and instead include it via gmock.h
70 # or a test_utils.h file, so make sure these cases are captured. Also include
71 # files that use <...> for #includes instead of quotes.
72 GTEST_INCLUDE_REGEX = re.compile(
73     r'#include.*(gtest|gmock|_test_utils|browser_test)\.h("|>)')
74
75
76 def ExitWithMessage(*args):
77   print(*args, file=sys.stderr)
78   sys.exit(1)
79
80
81 class TestValidity(Enum):
82   NOT_A_TEST = 0  # Does not match test file regex.
83   MAYBE_A_TEST = 1  # Matches test file regex, but doesn't include gtest files.
84   VALID_TEST = 2  # Matches test file regex and includes gtest files.
85
86
87 def IsTestFile(file_path):
88   if not TEST_FILE_NAME_REGEX.match(file_path):
89     return TestValidity.NOT_A_TEST
90   if file_path.endswith('.cc'):
91     # Try a bit harder to remove non-test files for c++. Without this,
92     # 'autotest.py base/' finds non-test files.
93     try:
94       with open(file_path, 'r', encoding='utf-8') as f:
95         if GTEST_INCLUDE_REGEX.search(f.read()) is not None:
96           return TestValidity.VALID_TEST
97     except IOError:
98       pass
99     # It may still be a test file, even if it doesn't include a gtest file.
100     return TestValidity.MAYBE_A_TEST
101   return TestValidity.VALID_TEST
102
103
104 class CommandError(Exception):
105   """Exception thrown when a subcommand fails."""
106
107   def __init__(self, command, return_code, output=None):
108     Exception.__init__(self)
109     self.command = command
110     self.return_code = return_code
111     self.output = output
112
113   def __str__(self):
114     message = (f'\n***\nERROR: Error while running command {self.command}'
115                f'.\nExit status: {self.return_code}\n')
116     if self.output:
117       message += f'Output:\n{self.output}\n'
118     message += '***'
119     return message
120
121
122 def StreamCommandOrExit(cmd, **kwargs):
123   try:
124     subprocess.check_call(cmd, **kwargs)
125   except subprocess.CalledProcessError as e:
126     sys.exit(1)
127
128
129 def RunCommand(cmd, **kwargs):
130   try:
131     # Set an encoding to convert the binary output to a string.
132     return subprocess.check_output(
133         cmd, **kwargs, encoding=locale.getpreferredencoding())
134   except subprocess.CalledProcessError as e:
135     raise CommandError(e.cmd, e.returncode, e.output) from None
136
137
138 def BuildTestTargets(out_dir, targets, dry_run):
139   """Builds the specified targets with ninja"""
140   cmd = gn_helpers.CreateBuildCommand(out_dir) + targets
141   print('Building: ' + shlex.join(cmd))
142   if (dry_run):
143     return True
144   try:
145     subprocess.check_call(cmd)
146   except subprocess.CalledProcessError as e:
147     return False
148   return True
149
150
151 def RecursiveMatchFilename(folder, filename):
152   current_dir = os.path.split(folder)[-1]
153   if current_dir.startswith('out') or current_dir.startswith('.'):
154     return [[], []]
155   exact = []
156   close = []
157   with os.scandir(folder) as it:
158     for entry in it:
159       if (entry.is_symlink()):
160         continue
161       if (entry.is_file() and filename in entry.path and
162           not os.path.basename(entry.path).startswith('.')):
163         file_validity = IsTestFile(entry.path)
164         if file_validity is TestValidity.VALID_TEST:
165           exact.append(entry.path)
166         elif file_validity is TestValidity.MAYBE_A_TEST:
167           close.append(entry.path)
168       if entry.is_dir():
169         # On Windows, junctions are like a symlink that python interprets as a
170         # directory, leading to exceptions being thrown. We can just catch and
171         # ignore these exceptions like we would ignore symlinks.
172         try:
173           matches = RecursiveMatchFilename(entry.path, filename)
174           exact += matches[0]
175           close += matches[1]
176         except FileNotFoundError as e:
177           if DEBUG:
178             print(f'Failed to scan directory "{entry}" - junction?')
179           pass
180   return [exact, close]
181
182
183 def FindTestFilesInDirectory(directory):
184   test_files = []
185   if DEBUG:
186     print('Test files:')
187   for root, _, files in os.walk(directory):
188     for f in files:
189       path = os.path.join(root, f)
190       file_validity = IsTestFile(path)
191       if file_validity is TestValidity.VALID_TEST:
192         if DEBUG:
193           print(path)
194         test_files.append(path)
195       elif DEBUG and file_validity is TestValidity.MAYBE_A_TEST:
196         print(path + ' matched but doesn\'t include gtest files, skipping.')
197   return test_files
198
199
200 def FindMatchingTestFiles(target):
201   # Return early if there's an exact file match.
202   if os.path.isfile(target):
203     # If the target is a C++ implementation file, try to guess the test file.
204     if target.endswith('.cc') or target.endswith('.h'):
205       target_validity = IsTestFile(target)
206       if target_validity is TestValidity.VALID_TEST:
207         return [target]
208       alternate = f"{target.rsplit('.', 1)[0]}_unittest.cc"
209       alt_validity = TestValidity.NOT_A_TEST if not os.path.isfile(
210           alternate) else IsTestFile(alternate)
211       if alt_validity is TestValidity.VALID_TEST:
212         return [alternate]
213
214       # If neither the target nor its alternative were valid, check if they just
215       # didn't include the gtest files before deciding to exit.
216       if target_validity is TestValidity.MAYBE_A_TEST:
217         return [target]
218       if alt_validity is TestValidity.MAYBE_A_TEST:
219         return [alternate]
220       ExitWithMessage(f"{target} doesn't look like a test file")
221     return [target]
222   # If this is a directory, return all the test files it contains.
223   if os.path.isdir(target):
224     files = FindTestFilesInDirectory(target)
225     if not files:
226       ExitWithMessage('No tests found in directory')
227     return files
228
229   if sys.platform.startswith('win32') and os.path.altsep in target:
230     # Use backslash as the path separator on Windows to match os.scandir().
231     if DEBUG:
232       print('Replacing ' + os.path.altsep + ' with ' + os.path.sep + ' in: '
233             + target)
234     target = target.replace(os.path.altsep, os.path.sep)
235   if DEBUG:
236     print('Finding files with full path containing: ' + target)
237
238   [exact, close] = RecursiveMatchFilename(SRC_DIR, target)
239   if DEBUG:
240     if exact:
241       print('Found exact matching file(s):')
242       print('\n'.join(exact))
243     if close:
244       print('Found possible matching file(s):')
245       print('\n'.join(close))
246
247   if len(exact) >= 1:
248     # Given "Foo", don't ask to disambiguate ModFoo.java vs Foo.java.
249     more_exact = [
250         p for p in exact if os.path.basename(p) in (target, f'{target}.java')
251     ]
252     if len(more_exact) == 1:
253       test_files = more_exact
254     else:
255       test_files = exact
256   else:
257     test_files = close
258
259   if len(test_files) > 1:
260     if len(test_files) < 10:
261       test_files = [HaveUserPickFile(test_files)]
262     else:
263       # Arbitrarily capping at 10 results so we don't print the name of every
264       # file in the repo if the target is poorly specified.
265       test_files = test_files[:10]
266       ExitWithMessage(f'Target "{target}" is ambiguous. Matching files: '
267                       f'{test_files}')
268   if not test_files:
269     ExitWithMessage(f'Target "{target}" did not match any files.')
270   return test_files
271
272
273 def HaveUserPickFile(paths):
274   paths = sorted(paths, key=lambda p: (len(p), p))
275   path_list = '\n'.join(f'{i}. {t}' for i, t in enumerate(paths))
276
277   while True:
278     user_input = input(f'Please choose the path you mean.\n{path_list}\n')
279     try:
280       value = int(user_input)
281       return paths[value]
282     except (ValueError, IndexError):
283       print('Try again')
284
285
286 def HaveUserPickTarget(paths, targets):
287   # Cap to 10 targets for convenience [0-9].
288   targets = targets[:10]
289   target_list = '\n'.join(f'{i}. {t}' for i, t in enumerate(targets))
290
291   user_input = input(f'Target "{paths}" is used by multiple test targets.\n' +
292                      target_list + '\nPlease pick a target: ')
293   try:
294     value = int(user_input)
295     return targets[value]
296   except (ValueError, IndexError):
297     print('Try again')
298     return HaveUserPickTarget(paths, targets)
299
300
301 # A persistent cache to avoid running gn on repeated runs of autotest.
302 class TargetCache:
303   def __init__(self, out_dir):
304     self.out_dir = out_dir
305     self.path = os.path.join(out_dir, 'autotest_cache')
306     self.gold_mtime = self.GetBuildNinjaMtime()
307     self.cache = {}
308     try:
309       mtime, cache = json.load(open(self.path, 'r'))
310       if mtime == self.gold_mtime:
311         self.cache = cache
312     except Exception:
313       pass
314
315   def Save(self):
316     with open(self.path, 'w') as f:
317       json.dump([self.gold_mtime, self.cache], f)
318
319   def Find(self, test_paths):
320     key = ' '.join(test_paths)
321     return self.cache.get(key, None)
322
323   def Store(self, test_paths, test_targets):
324     key = ' '.join(test_paths)
325     self.cache[key] = test_targets
326
327   def GetBuildNinjaMtime(self):
328     return os.path.getmtime(os.path.join(self.out_dir, 'build.ninja'))
329
330   def IsStillValid(self):
331     return self.GetBuildNinjaMtime() == self.gold_mtime
332
333
334 def _TestTargetsFromGnRefs(targets):
335   # First apply allowlists:
336   ret = [t for t in targets if '__' not in t]
337   ret = [
338       t for t in ret
339       if _TEST_TARGET_REGEX.search(t) or t in _TEST_TARGET_ALLOWLIST
340   ]
341   if ret:
342     return ret
343
344   _SUBTARGET_SUFFIXES = (
345       '__java_binary',  # robolectric_binary()
346       '__test_runner_script',  # test() targets
347       '__test_apk',  # instrumentation_test_apk() targets
348   )
349   ret = []
350   for suffix in _SUBTARGET_SUFFIXES:
351     ret.extend(t[:-len(suffix)] for t in targets if t.endswith(suffix))
352
353   return ret
354
355
356 # TODO(b/305968611) remove when goma is deprecated.
357 def _IsGomaNotice(target):
358   return target.startswith('The gn arg use_goma=true will be deprecated')
359
360
361 def FindTestTargets(target_cache, out_dir, paths, run_all):
362   # Normalize paths, so they can be cached.
363   paths = [os.path.realpath(p) for p in paths]
364   test_targets = target_cache.Find(paths)
365   used_cache = True
366   if not test_targets:
367     used_cache = False
368
369     # Use gn refs to recursively find all targets that depend on |path|, filter
370     # internal gn targets, and match against well-known test suffixes, falling
371     # back to a list of known test targets if that fails.
372     gn_path = os.path.join(DEPOT_TOOLS_DIR, 'gn')
373     if sys.platform.startswith('win32'):
374       gn_path += '.bat'
375
376     cmd = [gn_path, 'refs', out_dir, '--all'] + paths
377     targets = RunCommand(cmd).splitlines()
378     test_targets = _TestTargetsFromGnRefs(targets)
379
380     # If not targets were identified as tests by looking at their names, ask GN
381     # if any are executables.
382     if not test_targets and targets:
383       test_targets = RunCommand(cmd + ['--type=executable']).splitlines()
384
385   if not test_targets:
386     ExitWithMessage(
387         f'"{paths}" did not match any test targets. Consider adding'
388         f' one of the following targets to _TEST_TARGET_ALLOWLIST within '
389         f'{__file__}: \n' + '\n'.join(targets))
390
391   test_targets = [t for t in test_targets if not _IsGomaNotice(t)]
392   test_targets.sort()
393   target_cache.Store(paths, test_targets)
394   target_cache.Save()
395
396   if len(test_targets) > 1:
397     if run_all:
398       print(f'Warning, found {len(test_targets)} test targets.',
399             file=sys.stderr)
400       if len(test_targets) > 10:
401         ExitWithMessage('Your query likely involves non-test sources.')
402       print('Trying to run all of them!', file=sys.stderr)
403     else:
404       test_targets = [HaveUserPickTarget(paths, test_targets)]
405
406   # Remove the // prefix to turn GN label into ninja target.
407   test_targets = [t[2:] for t in test_targets]
408
409   return (test_targets, used_cache)
410
411
412 def RunTestTargets(out_dir, targets, gtest_filter, extra_args, dry_run,
413                    no_try_android_wrappers, no_fast_local_dev):
414
415   for target in targets:
416     target_binary = target.split(':')[1]
417
418     # Look for the Android wrapper script first.
419     path = os.path.join(out_dir, 'bin', f'run_{target_binary}')
420     if no_try_android_wrappers or not os.path.isfile(path):
421       # If the wrapper is not found or disabled use the Desktop target
422       # which is an executable.
423       path = os.path.join(out_dir, target_binary)
424     elif not no_fast_local_dev:
425       # Usually want this flag when developing locally.
426       extra_args = extra_args + ['--fast-local-dev']
427
428     cmd = [path, f'--gtest_filter={gtest_filter}'] + extra_args
429     print('Running test: ' + shlex.join(cmd))
430     if not dry_run:
431       StreamCommandOrExit(cmd)
432
433
434 def BuildCppTestFilter(filenames, line):
435   make_filter_command = [
436       sys.executable, SRC_DIR / 'tools' / 'make_gtest_filter.py'
437   ]
438   if line:
439     make_filter_command += ['--line', str(line)]
440   else:
441     make_filter_command += ['--class-only']
442   make_filter_command += filenames
443   return RunCommand(make_filter_command).strip()
444
445
446 def BuildJavaTestFilter(filenames):
447   return ':'.join('*.{}*'.format(os.path.splitext(os.path.basename(f))[0])
448                   for f in filenames)
449
450
451 def BuildTestFilter(filenames, line):
452   java_files = [f for f in filenames if f.endswith('.java')]
453   cc_files = [f for f in filenames if f.endswith('.cc')]
454   filters = []
455   if java_files:
456     filters.append(BuildJavaTestFilter(java_files))
457   if cc_files:
458     filters.append(BuildCppTestFilter(cc_files, line))
459
460   return ':'.join(filters)
461
462
463 def main():
464   parser = argparse.ArgumentParser(
465       description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
466   parser.add_argument('--out-dir',
467                       '--output-directory',
468                       '-C',
469                       metavar='OUT_DIR',
470                       help='output directory of the build')
471   parser.add_argument(
472       '--run-all',
473       action='store_true',
474       help='Run all tests for the file or directory, instead of just one')
475   parser.add_argument('--line',
476                       type=int,
477                       help='run only the test on this line number. c++ only.')
478   parser.add_argument('--gtest_filter',
479                       '--gtest-filter',
480                       '-f',
481                       metavar='FILTER',
482                       help='test filter')
483   parser.add_argument(
484       '--dry-run',
485       '-n',
486       action='store_true',
487       help='Print ninja and test run commands without executing them.')
488   parser.add_argument(
489       '--no-try-android-wrappers',
490       action='store_true',
491       help='Do not try to use Android test wrappers to run tests.')
492   parser.add_argument('--no-fast-local-dev',
493                       action='store_true',
494                       help='Do not add --fast-local-dev for Android tests.')
495   parser.add_argument('files',
496                       metavar='FILE_NAME',
497                       nargs="+",
498                       help='test suite file (eg. FooTest.java)')
499
500   args, _extras = parser.parse_known_args()
501
502   if args.out_dir:
503     constants.SetOutputDirectory(args.out_dir)
504   constants.CheckOutputDirectory()
505   out_dir: str = constants.GetOutDirectory()
506
507   if not os.path.isdir(out_dir):
508     parser.error(f'OUT_DIR "{out_dir}" does not exist.')
509   target_cache = TargetCache(out_dir)
510   filenames = []
511   for file in args.files:
512     filenames.extend(FindMatchingTestFiles(file))
513
514   targets, used_cache = FindTestTargets(target_cache, out_dir, filenames,
515                                         args.run_all)
516
517   gtest_filter = args.gtest_filter
518   if not gtest_filter:
519     gtest_filter = BuildTestFilter(filenames, args.line)
520
521   if not gtest_filter:
522     ExitWithMessage('Failed to derive a gtest filter')
523
524   assert targets
525   build_ok = BuildTestTargets(out_dir, targets, args.dry_run)
526
527   # If we used the target cache, it's possible we chose the wrong target because
528   # a gn file was changed. The build step above will check for gn modifications
529   # and update build.ninja. Use this opportunity the verify the cache is still
530   # valid.
531   if used_cache and not target_cache.IsStillValid():
532     target_cache = TargetCache(out_dir)
533     new_targets, _ = FindTestTargets(target_cache, out_dir, filenames,
534                                      args.run_all)
535     if targets != new_targets:
536       # Note that this can happen, for example, if you rename a test target.
537       print('gn config was changed, trying to build again', file=sys.stderr)
538       targets = new_targets
539       build_ok = BuildTestTargets(out_dir, targets, args.dry_run)
540
541   if not build_ok: sys.exit(1)
542
543   RunTestTargets(out_dir, targets, gtest_filter, _extras, args.dry_run,
544                  args.no_try_android_wrappers, args.no_fast_local_dev)
545
546
547 if __name__ == '__main__':
548   sys.exit(main())