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