ctest_driver.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. """
  2. Copyright (c) Contributors to the Open 3D Engine Project.
  3. For complete copyright and license terms please see the LICENSE at the root of this distribution.
  4. SPDX-License-Identifier: Apache-2.0 OR MIT
  5. A wrapper to simplify invoking CTest with common parameters and sub-filter to specific suites
  6. """
  7. import argparse
  8. import multiprocessing
  9. import os
  10. import result_processing.result_processing as rp
  11. import subprocess
  12. import sys
  13. import shutil
  14. SUITES_AND_DESCRIPTIONS = {
  15. "smoke": "Quick across-the-board set of tests designed to check if something is fundamentally broken",
  16. "main": "The default set of tests, covers most of all testing.",
  17. "periodic": "Tests which can take a long time and should be done periodially instead of every commit - these should not block code submission",
  18. "benchmark": "Benchmarks - instead of pass/fail, these collect data for comparison against historic data",
  19. "sandbox": "Flaky/Intermittent failing tests, this is used as a temporary spot to hold flaky tests, this will not block code submission. Ideally, this suite should always be empty",
  20. "awsi": "Time consuming AWS integration end-to-end tests"
  21. }
  22. BUILD_CONFIGURATIONS = [
  23. "profile",
  24. "debug",
  25. "release",
  26. ]
  27. def _regex_matching_any(words):
  28. """
  29. :param words: iterable of strings to match
  30. :return: a regex with groups to match each string
  31. """
  32. return "^(" + "|".join(words) + ")$"
  33. def run_single_test_suite(suite, ctest_path, cmake_build_path, build_config, disable_gpu, only_gpu, generate_xml, repeat, extra_args):
  34. """
  35. Starts CTest to filter down to a specific suite
  36. :param suite: subset of tests to run, see SUITES_AND_DESCRIPTIONS
  37. :param ctest_path: path to ctest.exe
  38. :param cmake_build_path: path to build output
  39. :param build_config: cmake build variant to select
  40. :param disable_gpu: optional, run only non-gpu tests
  41. :param only_gpu: optional, run only gpu-required tests
  42. :param generate_xml: optional, enable to produce the CTest xml file
  43. :param repeat: optional, number of times to run the tests in the suite
  44. :param extrargs: optional, forward args to ctest
  45. :return: CTest exit code
  46. """
  47. ctest_command = [
  48. ctest_path,
  49. "--build-config", build_config,
  50. "--output-on-failure",
  51. "--parallel", str(multiprocessing.cpu_count()), # leave serial vs parallel scheduling to CTest via set_tests_properties()
  52. "--no-tests=error",
  53. ]
  54. label_excludes = []
  55. label_includes = []
  56. # ctest can't actually do "AND" queries in label include and name-include, if any
  57. # match, it will accept them. In addition, the regex language it uses does
  58. # not include positive lookahead to use workarounds...
  59. # So if someone is asking for "main" AND "requires_gpu"
  60. # the only way to do this is to exclude all OTHER suites
  61. # to solve this problem generally, we will always exclude all other suites than
  62. # the one being tested.
  63. for label_name in SUITES_AND_DESCRIPTIONS.keys():
  64. if label_name != suite:
  65. label_excludes.append(f"SUITE_{label_name}")
  66. # only one of these can be true, or neither. If neither, we apply no REQUIRES_* filter.
  67. if only_gpu:
  68. label_includes.append("REQUIRES_gpu")
  69. elif disable_gpu:
  70. label_excludes.append("REQUIRES_gpu")
  71. union_regex = _regex_matching_any(label_includes) if label_includes else None
  72. difference_regex = _regex_matching_any(label_excludes) if label_excludes else None
  73. if union_regex:
  74. ctest_command.append("--label-regex")
  75. ctest_command.append(union_regex)
  76. if difference_regex:
  77. ctest_command.append("--label-exclude")
  78. ctest_command.append(difference_regex)
  79. if generate_xml:
  80. ctest_command.append('-T')
  81. ctest_command.append('Test')
  82. for extra_arg in extra_args:
  83. ctest_command.append(extra_arg)
  84. ctest_command_string = ' '.join(ctest_command) # ONLY used for display
  85. print(f"Executing CTest {repeat} time(s) with command:\n"
  86. f" {ctest_command_string}\n"
  87. "in working directory:\n"
  88. f" {cmake_build_path}\n")
  89. error_code = 0
  90. if repeat:
  91. # Run the tests multiple times. Previous test results are deleted, new test results are combined in a file per
  92. # test runner.
  93. test_result_prefix = 'Repeat'
  94. repeat = int(repeat)
  95. if generate_xml:
  96. rp.clean_test_results(cmake_build_path)
  97. for iteration in range(repeat):
  98. print(f"Executing CTest iteration {iteration + 1}/{repeat}")
  99. result = subprocess.run(ctest_command, shell=False, cwd=cmake_build_path, stdout=sys.stdout, stderr=sys.stderr)
  100. if generate_xml:
  101. rp.rename_test_results(cmake_build_path, test_result_prefix, iteration + 1, repeat)
  102. if result.returncode:
  103. error_code = result.returncode
  104. if generate_xml:
  105. rp.collect_test_results(cmake_build_path, test_result_prefix)
  106. summary = rp.summarize_test_results(cmake_build_path, repeat)
  107. print() # empty line
  108. print('Test stability summary:')
  109. if summary:
  110. print('The following test(s) failed:')
  111. for line in summary: print(line)
  112. else:
  113. print(f'All tests were executed {repeat} times and passed 100% of the time.')
  114. else:
  115. # Run the tests one time. Previous test results are not deleted but
  116. # might be overwritten.
  117. result = subprocess.run(ctest_command, shell=False, cwd=cmake_build_path, stdout=sys.stdout, stderr=sys.stderr)
  118. error_code = result.returncode
  119. return error_code
  120. def main():
  121. # establish defaults
  122. ctest_version = "3.17.0"
  123. if sys.platform == "win32":
  124. ctest_build = "Windows"
  125. ctest_relpath = "bin"
  126. ctest_exe = "ctest.exe"
  127. elif sys.platform.startswith("linux"):
  128. ctest_build = "Linux"
  129. ctest_relpath = "bin"
  130. ctest_exe = "ctest"
  131. elif sys.platform.startswith('darwin'):
  132. ctest_build = "Mac"
  133. ctest_relpath = "CMake.app/Contents/bin"
  134. ctest_exe = "ctest"
  135. else:
  136. raise NotImplementedError(f"CTest is not currently configured for platform '{sys.platform}'")
  137. current_script_path = os.path.dirname(__file__)
  138. dev_default = os.path.dirname(current_script_path)
  139. thirdparty_default = os.path.join(os.path.dirname(dev_default), "3rdParty")
  140. # if a specific known location contains cmake, we'll use it
  141. ctest_default = os.path.join(thirdparty_default, "CMake", ctest_version, ctest_build, ctest_relpath, ctest_exe)
  142. # parse args, with defaults
  143. parser = argparse.ArgumentParser(
  144. description="CTest CLI driver: simplifies providing common arguments to CTest",
  145. # extra wide help messages to avoid newlines appearing in path defaults, which break copy-paste of paths
  146. formatter_class=lambda prog: argparse.ArgumentDefaultsHelpFormatter(prog, width=4096),
  147. epilog="(Unrecognised parameters will be sent to ctest directly)"
  148. )
  149. parser.add_argument('-x', '--ctest-executable',
  150. help="Override path to the CTest executable (will use PATH env otherwise)")
  151. parser.add_argument('-B', '--build-path', # -B to match cmake's syntax for same thing.
  152. help="Path to a CMake build folder (generated by running cmake).",
  153. required=True)
  154. parser.add_argument('--config', choices=BUILD_CONFIGURATIONS, default="debug", # --config to match cmake
  155. help="CMake variant build configuration to target (debug/profile/release)")
  156. parser.add_argument('-s', '--suite', choices=SUITES_AND_DESCRIPTIONS.keys(),
  157. default="main",
  158. help="Which subset of tests to execute")
  159. parser.add_argument('--generate-xml', action='store_true',
  160. help='Enable this option to produce the CTest xml file.')
  161. parser.add_argument('-r', '--repeat', help="Run the tests the specified number times to identify intermittent "
  162. "failures (e.g. --repeat 3 for running the test three times). When used"
  163. "with --generate-xml, the resulting test reports will be combined, "
  164. "aggregated and summarized.", type=int)
  165. group = parser.add_mutually_exclusive_group()
  166. group.add_argument('--no-gpu', action='store_true',
  167. help="Disable tests that require a GPU")
  168. group.add_argument('--only-gpu', action='store_true',
  169. help="Run only tests that require a GPU")
  170. args, unknown_args = parser.parse_known_args()
  171. # handle the CTEST executable.
  172. # we always obey command line, and its an error if the command line has
  173. # a bad executable
  174. # if no command line is specified, it will fallback to a known good location
  175. # and then finally, use the PATH.
  176. if args.ctest_executable and not os.path.exists(args.ctest_executable):
  177. print(f"Error: Invalid ctest executable specified - not found: {args.ctest_executable}")
  178. return 1
  179. if not args.ctest_executable:
  180. # try the default
  181. if os.path.exists(ctest_default):
  182. print(f"Using default CTest executable: {ctest_default}")
  183. args.ctest_executable = ctest_default
  184. else: # try the PATH env var:
  185. found_ctest = shutil.which(ctest_exe)
  186. if found_ctest:
  187. print(f"Using CTest executable from PATH: {found_ctest}")
  188. args.ctest_executable = found_ctest
  189. else:
  190. print(f"Could not find CTest Executable ('{ctest_exe}')on PATH or in a pre-set location.")
  191. return 1
  192. # handle the build path. You must specify a build path, and it must contain CTestTestfile.cmake
  193. if not os.path.exists(args.build_path):
  194. print(f"Error: specified folder does not exist: {args.build_path}")
  195. return 1
  196. ctest_testfile = os.path.join(args.build_path, "CTestTestfile.cmake")
  197. if not os.path.exists(ctest_testfile):
  198. print(f"Error: '{ctest_testfile}' missing, run CMake configure+generate on the folder first.")
  199. return 1
  200. print(f"Starting '{args.suite}' suite: {SUITES_AND_DESCRIPTIONS[args.suite]}")
  201. # execute
  202. return run_single_test_suite(
  203. suite=args.suite,
  204. ctest_path=args.ctest_executable,
  205. cmake_build_path=args.build_path,
  206. build_config=args.config,
  207. disable_gpu=args.no_gpu,
  208. only_gpu=args.only_gpu,
  209. generate_xml=args.generate_xml,
  210. repeat=args.repeat,
  211. extra_args=unknown_args)
  212. if __name__ == "__main__":
  213. sys.exit(main())