UnitTestParser.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. from __future__ import print_function
  2. import argparse
  3. import os
  4. import re
  5. import shutil
  6. import subprocess
  7. from copy import deepcopy
  8. import CreateSectionTable
  9. import yaml
  10. try:
  11. from yaml import CLoader as Loader
  12. except ImportError:
  13. from yaml import Loader as Loader # type: ignore
  14. TEST_CASE_PATTERN = {
  15. 'initial condition': 'UTINIT1',
  16. 'chip_target': 'esp32',
  17. 'level': 'Unit',
  18. 'execution time': 0,
  19. 'auto test': 'Yes',
  20. 'category': 'Function',
  21. 'test point 1': 'basic function',
  22. 'version': 'v1 (2016-12-06)',
  23. 'test environment': 'UT_T1_1',
  24. 'reset': '',
  25. 'expected result': '1. set succeed',
  26. 'cmd set': 'test_unit_test_case',
  27. 'Test App': 'UT',
  28. }
  29. class Parser(object):
  30. """ parse unit test cases from build files and create files for test bench """
  31. TAG_PATTERN = re.compile(r'([^=]+)(=)?(.+)?')
  32. DESCRIPTION_PATTERN = re.compile(r'\[([^]\[]+)\]')
  33. CONFIG_PATTERN = re.compile(r'{([^}]+)}')
  34. TEST_GROUPS_PATTERN = re.compile(r'TEST_GROUPS=(.*)$')
  35. # file path (relative to idf path)
  36. TAG_DEF_FILE = os.path.join('tools', 'unit-test-app', 'tools', 'TagDefinition.yml')
  37. MODULE_DEF_FILE = os.path.join('tools', 'unit-test-app', 'tools', 'ModuleDefinition.yml')
  38. CONFIG_DEPENDENCY_FILE = os.path.join('tools', 'unit-test-app', 'tools', 'ConfigDependency.yml')
  39. MODULE_ARTIFACT_FILE = os.path.join('components', 'idf_test', 'ModuleDefinition.yml')
  40. TEST_CASE_FILE_DIR = os.path.join('components', 'idf_test', 'unit_test')
  41. UT_CONFIG_FOLDER = os.path.join('tools', 'unit-test-app', 'configs')
  42. ELF_FILE = 'unit-test-app.elf'
  43. SDKCONFIG_FILE = 'sdkconfig'
  44. STRIP_CONFIG_PATTERN = re.compile(r'(.+?)(_\d+)?$')
  45. TOOLCHAIN_FOR_TARGET = {
  46. 'esp32': 'xtensa-esp32-elf-',
  47. 'esp32s2': 'xtensa-esp32s2-elf-',
  48. 'esp32s3': 'xtensa-esp32s3-elf-',
  49. 'esp32c3': 'riscv32-esp-elf-',
  50. }
  51. def __init__(self, binary_folder, node_index):
  52. idf_path = os.getenv('IDF_PATH')
  53. idf_target = os.getenv('IDF_TARGET')
  54. self.test_env_tags = {}
  55. self.unit_jobs = {}
  56. self.file_name_cache = {}
  57. self.idf_path = idf_path
  58. self.idf_target = idf_target
  59. self.node_index = node_index
  60. self.ut_bin_folder = binary_folder
  61. self.objdump = Parser.TOOLCHAIN_FOR_TARGET.get(idf_target, '') + 'objdump'
  62. self.tag_def = yaml.load(open(os.path.join(idf_path, self.TAG_DEF_FILE), 'r'), Loader=Loader)
  63. self.module_map = yaml.load(open(os.path.join(idf_path, self.MODULE_DEF_FILE), 'r'), Loader=Loader)
  64. self.config_dependencies = yaml.load(open(os.path.join(idf_path, self.CONFIG_DEPENDENCY_FILE), 'r'),
  65. Loader=Loader)
  66. # used to check if duplicated test case names
  67. self.test_case_names = set()
  68. self.parsing_errors = []
  69. def parse_test_cases_for_one_config(self, configs_folder, config_output_folder, config_name):
  70. """
  71. parse test cases from elf and save test cases need to be executed to unit test folder
  72. :param configs_folder: folder where per-config sdkconfig fragments are located (i.e. tools/unit-test-app/configs)
  73. :param config_output_folder: build folder of this config
  74. :param config_name: built unit test config name
  75. """
  76. tags = self.parse_tags(os.path.join(config_output_folder, self.SDKCONFIG_FILE))
  77. print('Tags of config %s: %s' % (config_name, tags))
  78. test_groups = self.get_test_groups(os.path.join(configs_folder, config_name))
  79. elf_file = os.path.join(config_output_folder, self.ELF_FILE)
  80. subprocess.check_output('{} -t {} | grep test_desc > case_address.tmp'.format(self.objdump, elf_file),
  81. shell=True)
  82. subprocess.check_output('{} -s {} > section_table.tmp'.format(self.objdump, elf_file), shell=True)
  83. table = CreateSectionTable.SectionTable('section_table.tmp')
  84. test_cases = []
  85. # we could split cases of same config into multiple binaries as we have limited rom space
  86. # we should regard those configs like `default` and `default_2` as the same config
  87. match = self.STRIP_CONFIG_PATTERN.match(config_name)
  88. stripped_config_name = match.group(1)
  89. with open('case_address.tmp', 'rb') as f:
  90. for line in f:
  91. # process symbol table like: "3ffb4310 l O .dram0.data 00000018 test_desc_33$5010"
  92. line = line.split()
  93. test_addr = int(line[0], 16)
  94. section = line[3]
  95. name_addr = table.get_unsigned_int(section, test_addr, 4)
  96. desc_addr = table.get_unsigned_int(section, test_addr + 4, 4)
  97. function_count = table.get_unsigned_int(section, test_addr + 20, 4)
  98. name = table.get_string('any', name_addr)
  99. desc = table.get_string('any', desc_addr)
  100. tc = self.parse_one_test_case(name, desc, config_name, stripped_config_name, tags)
  101. # check if duplicated case names
  102. # we need to use it to select case,
  103. # if duplicated IDs, Unity could select incorrect case to run
  104. # and we need to check all cases no matter if it's going te be executed by CI
  105. # also add app_name here, we allow same case for different apps
  106. if (tc['summary'] + stripped_config_name) in self.test_case_names:
  107. self.parsing_errors.append('{} ({}): duplicated test case ID: {}'.format(stripped_config_name, config_name, tc['summary']))
  108. else:
  109. self.test_case_names.add(tc['summary'] + stripped_config_name)
  110. test_group_included = True
  111. if test_groups is not None and tc['group'] not in test_groups:
  112. test_group_included = False
  113. if tc['CI ready'] == 'Yes' and test_group_included:
  114. # update test env list and the cases of same env list
  115. if tc['test environment'] in self.test_env_tags:
  116. self.test_env_tags[tc['test environment']].append(tc['ID'])
  117. else:
  118. self.test_env_tags.update({tc['test environment']: [tc['ID']]})
  119. if function_count > 1:
  120. tc.update({'child case num': function_count})
  121. # only add cases need to be executed
  122. test_cases.append(tc)
  123. os.remove('section_table.tmp')
  124. os.remove('case_address.tmp')
  125. return test_cases
  126. def parse_case_properties(self, tags_raw):
  127. """
  128. parse test case tags (properties) with the following rules:
  129. * first tag is always group of test cases, it's mandatory
  130. * the rest tags should be [type=value].
  131. * if the type have default value, then [type] equal to [type=default_value].
  132. * if the type don't don't exist, then equal to [type=omitted_value]
  133. default_value and omitted_value are defined in TagDefinition.yml
  134. :param tags_raw: raw tag string
  135. :return: tag dict
  136. """
  137. tags = self.DESCRIPTION_PATTERN.findall(tags_raw)
  138. assert len(tags) > 0
  139. p = dict([(k, self.tag_def[k]['omitted']) for k in self.tag_def])
  140. p['module'] = tags[0]
  141. # Use the original value of the first tag as test group name
  142. p['group'] = p['module']
  143. if p['module'] not in self.module_map:
  144. p['module'] = 'misc'
  145. # parsing rest tags, [type=value], =value is optional
  146. for tag in tags[1:]:
  147. match = self.TAG_PATTERN.search(tag)
  148. assert match is not None
  149. tag_type = match.group(1)
  150. tag_value = match.group(3)
  151. if match.group(2) == '=' and tag_value is None:
  152. # [tag_type=] means tag_value is empty string
  153. tag_value = ''
  154. if tag_type in p:
  155. if tag_value is None:
  156. p[tag_type] = self.tag_def[tag_type]['default']
  157. else:
  158. p[tag_type] = tag_value
  159. else:
  160. # ignore not defined tag type
  161. pass
  162. return p
  163. @staticmethod
  164. def parse_tags_internal(sdkconfig, config_dependencies, config_pattern):
  165. required_tags = []
  166. def compare_config(config):
  167. return config in sdkconfig
  168. def process_condition(condition):
  169. matches = config_pattern.findall(condition)
  170. if matches:
  171. for config in matches:
  172. compare_result = compare_config(config)
  173. # replace all configs in condition with True or False according to compare result
  174. condition = re.sub(config_pattern, str(compare_result), condition, count=1)
  175. # Now the condition is a python condition, we can use eval to compute its value
  176. ret = eval(condition)
  177. else:
  178. # didn't use complex condition. only defined one condition for the tag
  179. ret = compare_config(condition)
  180. return ret
  181. for tag in config_dependencies:
  182. if process_condition(config_dependencies[tag]):
  183. required_tags.append(tag)
  184. return required_tags
  185. def parse_tags(self, sdkconfig_file):
  186. """
  187. Some test configs could requires different DUTs.
  188. For example, if CONFIG_ESP32_SPIRAM_SUPPORT is enabled, we need WROVER-Kit to run test.
  189. This method will get tags for runners according to ConfigDependency.yml(maps tags to sdkconfig).
  190. We support to the following syntax::
  191. # define the config which requires the tag
  192. 'tag_a': 'config_a="value_a"'
  193. # define the condition for the tag
  194. 'tag_b': '{config A} and (not {config B} or (not {config C} and {config D}))'
  195. :param sdkconfig_file: sdk config file of the unit test config
  196. :return: required tags for runners
  197. """
  198. with open(sdkconfig_file, 'r') as f:
  199. configs_raw_data = f.read()
  200. configs = configs_raw_data.splitlines(False)
  201. return self.parse_tags_internal(configs, self.config_dependencies, self.CONFIG_PATTERN)
  202. def get_test_groups(self, config_file):
  203. """
  204. If the config file includes TEST_GROUPS variable, return its value as a list of strings.
  205. :param config_file file under configs/ directory for given configuration
  206. :return: list of test groups, or None if TEST_GROUPS wasn't set
  207. """
  208. with open(config_file, 'r') as f:
  209. for line in f:
  210. match = self.TEST_GROUPS_PATTERN.match(line)
  211. if match is not None:
  212. return match.group(1).split(' ')
  213. return None
  214. def parse_one_test_case(self, name, description, config_name, stripped_config_name, tags):
  215. """
  216. parse one test case
  217. :param name: test case name (summary)
  218. :param description: test case description (tag string)
  219. :param config_name: built unit test app name
  220. :param stripped_config_name: strip suffix from config name because they're the same except test components
  221. :param tags: tags to select runners
  222. :return: parsed test case
  223. """
  224. prop = self.parse_case_properties(description)
  225. test_case = deepcopy(TEST_CASE_PATTERN)
  226. test_case.update({'config': config_name,
  227. 'module': self.module_map[prop['module']]['module'],
  228. 'group': prop['group'],
  229. 'CI ready': 'No' if prop['ignore'] == 'Yes' else 'Yes',
  230. 'ID': '[{}] {}'.format(stripped_config_name, name),
  231. 'test point 2': prop['module'],
  232. 'steps': name,
  233. 'test environment': prop['test_env'],
  234. 'reset': prop['reset'],
  235. 'sub module': self.module_map[prop['module']]['sub module'],
  236. 'summary': name,
  237. 'multi_device': prop['multi_device'],
  238. 'multi_stage': prop['multi_stage'],
  239. 'timeout': int(prop['timeout']),
  240. 'tags': tags,
  241. 'chip_target': self.idf_target})
  242. return test_case
  243. def dump_test_cases(self, test_cases):
  244. """
  245. dump parsed test cases to YAML file for test bench input
  246. :param test_cases: parsed test cases
  247. """
  248. filename = os.path.join(self.idf_path, self.TEST_CASE_FILE_DIR,
  249. '{}_{}.yml'.format(self.idf_target, self.node_index))
  250. try:
  251. os.mkdir(os.path.dirname(filename))
  252. except OSError:
  253. pass
  254. with open(os.path.join(filename), 'w+') as f:
  255. yaml.dump({'test cases': test_cases}, f, allow_unicode=True, default_flow_style=False)
  256. def copy_module_def_file(self):
  257. """ copy module def file to artifact path """
  258. src = os.path.join(self.idf_path, self.MODULE_DEF_FILE)
  259. dst = os.path.join(self.idf_path, self.MODULE_ARTIFACT_FILE)
  260. shutil.copy(src, dst)
  261. def parse_test_cases(self):
  262. """ parse test cases from multiple built unit test apps """
  263. test_cases = []
  264. output_folder = os.path.join(self.idf_path, self.ut_bin_folder, self.idf_target)
  265. configs_folder = os.path.join(self.idf_path, self.UT_CONFIG_FOLDER)
  266. test_configs = [item for item in os.listdir(output_folder)
  267. if os.path.isdir(os.path.join(output_folder, item))]
  268. for config in test_configs:
  269. config_output_folder = os.path.join(output_folder, config)
  270. if os.path.exists(config_output_folder):
  271. test_cases.extend(self.parse_test_cases_for_one_config(configs_folder, config_output_folder, config))
  272. test_cases.sort(key=lambda x: x['config'] + x['summary'])
  273. self.dump_test_cases(test_cases)
  274. def test_parser(binary_folder, node_index):
  275. ut_parser = Parser(binary_folder, node_index)
  276. # test parsing tags
  277. # parsing module only and module in module list
  278. prop = ut_parser.parse_case_properties('[esp32]')
  279. assert prop['module'] == 'esp32'
  280. # module not in module list
  281. prop = ut_parser.parse_case_properties('[not_in_list]')
  282. assert prop['module'] == 'misc'
  283. # parsing a default tag, a tag with assigned value
  284. prop = ut_parser.parse_case_properties('[esp32][ignore][test_env=ABCD][not_support1][not_support2=ABCD]')
  285. assert prop['ignore'] == 'Yes' and prop['test_env'] == 'ABCD' \
  286. and 'not_support1' not in prop and 'not_supported2' not in prop
  287. # parsing omitted value
  288. prop = ut_parser.parse_case_properties('[esp32]')
  289. assert prop['ignore'] == 'No' and prop['test_env'] == 'UT_T1_1'
  290. # parsing with incorrect format
  291. try:
  292. ut_parser.parse_case_properties('abcd')
  293. assert False
  294. except AssertionError:
  295. pass
  296. # skip invalid data parse, [type=] assigns empty string to type
  297. prop = ut_parser.parse_case_properties('[esp32]abdc aaaa [ignore=]')
  298. assert prop['module'] == 'esp32' and prop['ignore'] == ''
  299. # skip mis-paired []
  300. prop = ut_parser.parse_case_properties('[esp32][[ignore=b]][]][test_env=AAA]]')
  301. assert prop['module'] == 'esp32' and prop['ignore'] == 'b' and prop['test_env'] == 'AAA'
  302. config_dependency = {
  303. 'a': '123',
  304. 'b': '456',
  305. 'c': 'not {123}',
  306. 'd': '{123} and not {456}',
  307. 'e': '{123} and not {789}',
  308. 'f': '({123} and {456}) or ({123} and {789})'
  309. }
  310. sdkconfig = ['123', '789']
  311. tags = ut_parser.parse_tags_internal(sdkconfig, config_dependency, ut_parser.CONFIG_PATTERN)
  312. assert sorted(tags) == ['a', 'd', 'f'] # sorted is required for older Python3, e.g. 3.4.8
  313. def main(binary_folder, node_index):
  314. assert os.getenv('IDF_PATH'), 'IDF_PATH must be set to use this script'
  315. assert os.getenv('IDF_TARGET'), 'IDF_TARGET must be set to use this script'
  316. test_parser(binary_folder, node_index)
  317. ut_parser = Parser(binary_folder, node_index)
  318. ut_parser.parse_test_cases()
  319. ut_parser.copy_module_def_file()
  320. if len(ut_parser.parsing_errors) > 0:
  321. for error in ut_parser.parsing_errors:
  322. print(error)
  323. exit(1)
  324. if __name__ == '__main__':
  325. parser = argparse.ArgumentParser()
  326. parser.add_argument('bin_dir', help='Binary Folder')
  327. parser.add_argument('node_index', type=int, default=1,
  328. help='Node index, should only be set in CI')
  329. args = parser.parse_args()
  330. main(args.bin_dir, args.node_index)