IDFAssignTest.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. # SPDX-FileCopyrightText: 2022 Espressif Systems (Shanghai) CO LTD
  2. # SPDX-License-Identifier: Apache-2.0
  3. """
  4. Command line tool to assign tests to CI test jobs.
  5. """
  6. import argparse
  7. import errno
  8. import json
  9. import os
  10. import re
  11. from copy import deepcopy
  12. import yaml
  13. try:
  14. from yaml import CLoader as Loader
  15. except ImportError:
  16. from yaml import Loader as Loader # type: ignore
  17. import gitlab_api
  18. from tiny_test_fw.Utility import CIAssignTest
  19. try:
  20. from idf_py_actions.constants import PREVIEW_TARGETS, SUPPORTED_TARGETS
  21. except ImportError:
  22. SUPPORTED_TARGETS = []
  23. PREVIEW_TARGETS = []
  24. IDF_PATH_FROM_ENV = os.getenv('IDF_PATH', '')
  25. class IDFCaseGroup(CIAssignTest.Group):
  26. LOCAL_BUILD_DIR = None
  27. BUILD_JOB_NAMES = None
  28. @classmethod
  29. def get_artifact_index_file(cls):
  30. assert cls.LOCAL_BUILD_DIR
  31. if IDF_PATH_FROM_ENV:
  32. artifact_index_file = os.path.join(IDF_PATH_FROM_ENV, cls.LOCAL_BUILD_DIR, 'artifact_index.json')
  33. else:
  34. artifact_index_file = 'artifact_index.json'
  35. return artifact_index_file
  36. class IDFAssignTest(CIAssignTest.AssignTest):
  37. DEFAULT_FILTER = {
  38. 'category': 'function',
  39. 'ignore': False,
  40. 'supported_in_ci': True,
  41. 'nightly_run': False,
  42. }
  43. def __init__(self, test_case_path, ci_config_file, case_group=IDFCaseGroup):
  44. super(IDFAssignTest, self).__init__(test_case_path, ci_config_file, case_group)
  45. def format_build_log_path(self, parallel_num):
  46. return '{}/list_job_{}.json'.format(self.case_group.LOCAL_BUILD_DIR, parallel_num)
  47. def create_artifact_index_file(self, project_id=None, pipeline_id=None):
  48. if project_id is None:
  49. project_id = os.getenv('CI_PROJECT_ID')
  50. if pipeline_id is None:
  51. pipeline_id = os.getenv('CI_PIPELINE_ID')
  52. gitlab_inst = gitlab_api.Gitlab(project_id)
  53. artifact_index_list = []
  54. for build_job_name in self.case_group.BUILD_JOB_NAMES:
  55. job_info_list = gitlab_inst.find_job_id(build_job_name, pipeline_id=pipeline_id)
  56. for job_info in job_info_list:
  57. parallel_num = job_info['parallel_num'] or 1 # Could be None if "parallel_num" not defined for the job
  58. raw_data = gitlab_inst.download_artifact(job_info['id'],
  59. [self.format_build_log_path(parallel_num)])[0]
  60. build_info_list = [json.loads(line) for line in raw_data.decode().splitlines()]
  61. for build_info in build_info_list:
  62. build_info['ci_job_id'] = job_info['id']
  63. artifact_index_list.append(build_info)
  64. artifact_index_file = self.case_group.get_artifact_index_file()
  65. try:
  66. os.makedirs(os.path.dirname(artifact_index_file))
  67. except OSError as e:
  68. if e.errno != errno.EEXIST:
  69. raise e
  70. with open(artifact_index_file, 'w') as f:
  71. json.dump(artifact_index_list, f)
  72. def search_cases(self, case_filter=None):
  73. _filter = deepcopy(case_filter) if case_filter else {}
  74. if 'NIGHTLY_RUN' in os.environ or 'BOT_LABEL_NIGHTLY_RUN' in os.environ:
  75. _filter.update({'nightly_run': True})
  76. return super().search_cases(_filter)
  77. class ExampleGroup(IDFCaseGroup):
  78. SORT_KEYS = CI_JOB_MATCH_KEYS = ['env_tag', 'target']
  79. LOCAL_BUILD_DIR = 'build_examples' # type: ignore
  80. EXAMPLE_TARGETS = SUPPORTED_TARGETS + PREVIEW_TARGETS
  81. BUILD_JOB_NAMES = ['build_examples_cmake_{}'.format(target) for target in EXAMPLE_TARGETS] # type: ignore
  82. class TestAppsGroup(ExampleGroup):
  83. LOCAL_BUILD_DIR = 'build_test_apps'
  84. TEST_APP_TARGETS = SUPPORTED_TARGETS + PREVIEW_TARGETS
  85. BUILD_JOB_NAMES = ['build_test_apps_{}'.format(target) for target in TEST_APP_TARGETS] # type: ignore
  86. class ComponentUTGroup(TestAppsGroup):
  87. LOCAL_BUILD_DIR = 'build_component_ut'
  88. UNIT_TEST_TARGETS = SUPPORTED_TARGETS + PREVIEW_TARGETS
  89. BUILD_JOB_NAMES = ['build_component_ut_{}'.format(target) for target in UNIT_TEST_TARGETS] # type: ignore
  90. class UnitTestGroup(IDFCaseGroup):
  91. SORT_KEYS = ['test environment', 'tags', 'chip_target']
  92. CI_JOB_MATCH_KEYS = ['test environment']
  93. LOCAL_BUILD_DIR = 'tools/unit-test-app/builds' # type: ignore
  94. UNIT_TEST_TARGETS = SUPPORTED_TARGETS + PREVIEW_TARGETS
  95. BUILD_JOB_NAMES = ['build_esp_idf_tests_cmake_{}'.format(target) for target in UNIT_TEST_TARGETS] # type: ignore
  96. MAX_CASE = 50
  97. ATTR_CONVERT_TABLE = {
  98. 'execution_time': 'execution time'
  99. }
  100. DUT_CLS_NAME = {
  101. 'esp32': 'ESP32DUT',
  102. 'esp32s2': 'ESP32S2DUT',
  103. 'esp32s3': 'ESP32S3DUT',
  104. 'esp32c2': 'ESP32C2DUT',
  105. 'esp32c3': 'ESP32C3DUT',
  106. 'esp8266': 'ESP8266DUT',
  107. }
  108. def __init__(self, case):
  109. super(UnitTestGroup, self).__init__(case)
  110. for tag in self._get_case_attr(case, 'tags'):
  111. self.ci_job_match_keys.add(tag)
  112. @staticmethod
  113. def _get_case_attr(case, attr):
  114. if attr in UnitTestGroup.ATTR_CONVERT_TABLE:
  115. attr = UnitTestGroup.ATTR_CONVERT_TABLE[attr]
  116. return case[attr]
  117. def add_extra_case(self, case):
  118. """ If current group contains all tags required by case, then add succeed """
  119. added = False
  120. if self.accept_new_case():
  121. for key in self.filters:
  122. if self._get_case_attr(case, key) != self.filters[key]:
  123. if key == 'tags':
  124. if set(self._get_case_attr(case, key)).issubset(set(self.filters[key])):
  125. continue
  126. break
  127. else:
  128. self.case_list.append(case)
  129. added = True
  130. return added
  131. def _create_extra_data(self, test_cases, test_function):
  132. """
  133. For unit test case, we need to copy some attributes of test cases into config file.
  134. So unit test function knows how to run the case.
  135. """
  136. case_data = []
  137. for case in test_cases:
  138. one_case_data = {
  139. 'config': self._get_case_attr(case, 'config'),
  140. 'name': self._get_case_attr(case, 'summary'),
  141. 'reset': self._get_case_attr(case, 'reset'),
  142. 'timeout': self._get_case_attr(case, 'timeout'),
  143. }
  144. if test_function in ['run_multiple_devices_cases', 'run_multiple_stage_cases']:
  145. try:
  146. one_case_data['child case num'] = self._get_case_attr(case, 'child case num')
  147. except KeyError as e:
  148. print('multiple devices/stages cases must contains at least two test functions')
  149. print('case name: {}'.format(one_case_data['name']))
  150. raise e
  151. case_data.append(one_case_data)
  152. return case_data
  153. def _divide_case_by_test_function(self):
  154. """
  155. divide cases of current test group by test function they need to use
  156. :return: dict of list of cases for each test functions
  157. """
  158. case_by_test_function = {
  159. 'run_multiple_devices_cases': [],
  160. 'run_multiple_stage_cases': [],
  161. 'run_unit_test_cases': [],
  162. }
  163. for case in self.case_list:
  164. if case['multi_device'] == 'Yes':
  165. case_by_test_function['run_multiple_devices_cases'].append(case)
  166. elif case['multi_stage'] == 'Yes':
  167. case_by_test_function['run_multiple_stage_cases'].append(case)
  168. else:
  169. case_by_test_function['run_unit_test_cases'].append(case)
  170. return case_by_test_function
  171. def output(self):
  172. """
  173. output data for job configs
  174. :return: {"Filter": case filter, "CaseConfig": list of case configs for cases in this group}
  175. """
  176. target = self._get_case_attr(self.case_list[0], 'chip_target')
  177. if target:
  178. overwrite = {
  179. 'dut': {
  180. 'package': 'ttfw_idf',
  181. 'class': self.DUT_CLS_NAME[target],
  182. }
  183. }
  184. else:
  185. overwrite = dict()
  186. case_by_test_function = self._divide_case_by_test_function()
  187. output_data = {
  188. # we don't need filter for test function, as UT uses a few test functions for all cases
  189. 'CaseConfig': [
  190. {
  191. 'name': test_function,
  192. 'extra_data': self._create_extra_data(test_cases, test_function),
  193. 'overwrite': overwrite,
  194. } for test_function, test_cases in case_by_test_function.items() if test_cases
  195. ],
  196. }
  197. return output_data
  198. class ExampleAssignTest(IDFAssignTest):
  199. CI_TEST_JOB_PATTERN = re.compile(r'^example_test_.+')
  200. def __init__(self, test_case_path, ci_config_file):
  201. super(ExampleAssignTest, self).__init__(test_case_path, ci_config_file, case_group=ExampleGroup)
  202. class TestAppsAssignTest(IDFAssignTest):
  203. CI_TEST_JOB_PATTERN = re.compile(r'^test_app_test_.+')
  204. def __init__(self, test_case_path, ci_config_file):
  205. super(TestAppsAssignTest, self).__init__(test_case_path, ci_config_file, case_group=TestAppsGroup)
  206. class ComponentUTAssignTest(IDFAssignTest):
  207. CI_TEST_JOB_PATTERN = re.compile(r'^component_ut_test_.+')
  208. def __init__(self, test_case_path, ci_config_file):
  209. super(ComponentUTAssignTest, self).__init__(test_case_path, ci_config_file, case_group=ComponentUTGroup)
  210. class UnitTestAssignTest(IDFAssignTest):
  211. CI_TEST_JOB_PATTERN = re.compile(r'^UT_.+')
  212. def __init__(self, test_case_path, ci_config_file):
  213. super(UnitTestAssignTest, self).__init__(test_case_path, ci_config_file, case_group=UnitTestGroup)
  214. def search_cases(self, case_filter=None):
  215. """
  216. For unit test case, we don't search for test functions.
  217. The unit test cases is stored in a yaml file which is created in job build-idf-test.
  218. """
  219. def find_by_suffix(suffix, path):
  220. res = []
  221. for root, _, files in os.walk(path):
  222. for file in files:
  223. if file.endswith(suffix):
  224. res.append(os.path.join(root, file))
  225. return res
  226. def get_test_cases_from_yml(yml_file):
  227. try:
  228. with open(yml_file) as fr:
  229. raw_data = yaml.load(fr, Loader=Loader)
  230. test_cases = raw_data['test cases']
  231. except (IOError, KeyError):
  232. return []
  233. else:
  234. return test_cases
  235. test_cases = []
  236. for path in self.test_case_paths:
  237. if os.path.isdir(path):
  238. for yml_file in find_by_suffix('.yml', path):
  239. test_cases.extend(get_test_cases_from_yml(yml_file))
  240. elif os.path.isfile(path) and path.endswith('.yml'):
  241. test_cases.extend(get_test_cases_from_yml(path))
  242. else:
  243. print('Test case path is invalid. Should only happen when use @bot to skip unit test.')
  244. # filter keys are lower case. Do map lower case keys with original keys.
  245. try:
  246. key_mapping = {x.lower(): x for x in test_cases[0].keys()}
  247. except IndexError:
  248. key_mapping = dict()
  249. if case_filter:
  250. for key in case_filter:
  251. filtered_cases = []
  252. for case in test_cases:
  253. try:
  254. mapped_key = key_mapping[key]
  255. # bot converts string to lower case
  256. if isinstance(case[mapped_key], str):
  257. _value = case[mapped_key].lower()
  258. else:
  259. _value = case[mapped_key]
  260. if _value in case_filter[key]:
  261. filtered_cases.append(case)
  262. except KeyError:
  263. # case don't have this key, regard as filter success
  264. filtered_cases.append(case)
  265. test_cases = filtered_cases
  266. # sort cases with configs and test functions
  267. # in later stage cases with similar attributes are more likely to be assigned to the same job
  268. # it will reduce the count of flash DUT operations
  269. test_cases.sort(key=lambda x: x['config'] + x['multi_stage'] + x['multi_device'])
  270. return test_cases
  271. if __name__ == '__main__':
  272. parser = argparse.ArgumentParser()
  273. parser.add_argument('case_group', choices=['example_test', 'custom_test', 'unit_test', 'component_ut'])
  274. parser.add_argument('test_case_paths', nargs='+', help='test case folder or file')
  275. parser.add_argument('-c', '--config', default=os.path.join(IDF_PATH_FROM_ENV, '.gitlab', 'ci', 'target-test.yml'),
  276. help='gitlab ci config file')
  277. parser.add_argument('-o', '--output', help='output path of config files')
  278. parser.add_argument('--pipeline_id', '-p', type=int, default=None, help='pipeline_id')
  279. parser.add_argument('--test-case-file-pattern', help='file name pattern used to find Python test case files')
  280. args = parser.parse_args()
  281. SUPPORTED_TARGETS.extend(PREVIEW_TARGETS)
  282. test_case_paths = [os.path.join(IDF_PATH_FROM_ENV, path) if not os.path.isabs(path) else path for path in
  283. args.test_case_paths] # type: ignore
  284. args_list = [test_case_paths, args.config]
  285. if args.case_group == 'example_test':
  286. assigner = ExampleAssignTest(*args_list)
  287. elif args.case_group == 'custom_test':
  288. assigner = TestAppsAssignTest(*args_list)
  289. elif args.case_group == 'unit_test':
  290. assigner = UnitTestAssignTest(*args_list)
  291. elif args.case_group == 'component_ut':
  292. assigner = ComponentUTAssignTest(*args_list)
  293. else:
  294. raise SystemExit(1) # which is impossible
  295. if args.test_case_file_pattern:
  296. assigner.CI_TEST_JOB_PATTERN = re.compile(r'{}'.format(args.test_case_file_pattern))
  297. assigner.assign_cases()
  298. assigner.output_configs(args.output)
  299. assigner.create_artifact_index_file()