11b16049aSZach Atkinsfrom abc import ABC, abstractmethod 21b16049aSZach Atkinsimport argparse 369ef23b6SZach Atkinsimport csv 41b16049aSZach Atkinsfrom dataclasses import dataclass, field 51b16049aSZach Atkinsimport difflib 61b16049aSZach Atkinsfrom enum import Enum 71b16049aSZach Atkinsfrom math import isclose 81b16049aSZach Atkinsimport os 91b16049aSZach Atkinsfrom pathlib import Path 101b16049aSZach Atkinsimport re 111b16049aSZach Atkinsimport subprocess 1219868e18SZach Atkinsimport multiprocessing as mp 1319868e18SZach Atkinsfrom itertools import product 141b16049aSZach Atkinsimport sys 151b16049aSZach Atkinsimport time 1678cb100bSJames Wrightfrom typing import Optional, Tuple, List 171b16049aSZach Atkins 181b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml")) 191b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string # nopep8 201b16049aSZach Atkins 211b16049aSZach Atkins 221b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action): 231b16049aSZach Atkins """Action to convert input values to lower case prior to converting to an Enum type""" 241b16049aSZach Atkins 251b16049aSZach Atkins def __init__(self, option_strings, dest, type, default, **kwargs): 261b16049aSZach Atkins if not (issubclass(type, Enum) and issubclass(type, str)): 271b16049aSZach Atkins raise ValueError(f"{type} must be a StrEnum or str and Enum") 281b16049aSZach Atkins # store provided enum type 291b16049aSZach Atkins self.enum_type = type 301b16049aSZach Atkins if isinstance(default, str): 311b16049aSZach Atkins default = self.enum_type(default.lower()) 321b16049aSZach Atkins else: 331b16049aSZach Atkins default = [self.enum_type(v.lower()) for v in default] 341b16049aSZach Atkins # prevent automatic type conversion 351b16049aSZach Atkins super().__init__(option_strings, dest, default=default, **kwargs) 361b16049aSZach Atkins 371b16049aSZach Atkins def __call__(self, parser, namespace, values, option_string=None): 381b16049aSZach Atkins if isinstance(values, str): 391b16049aSZach Atkins values = self.enum_type(values.lower()) 401b16049aSZach Atkins else: 411b16049aSZach Atkins values = [self.enum_type(v.lower()) for v in values] 421b16049aSZach Atkins setattr(namespace, self.dest, values) 431b16049aSZach Atkins 441b16049aSZach Atkins 451b16049aSZach Atkins@dataclass 461b16049aSZach Atkinsclass TestSpec: 471b16049aSZach Atkins """Dataclass storing information about a single test case""" 481b16049aSZach Atkins name: str 498938a869SZach Atkins only: List = field(default_factory=list) 508938a869SZach Atkins args: List = field(default_factory=list) 511b16049aSZach Atkins 521b16049aSZach Atkins 531b16049aSZach Atkinsclass RunMode(str, Enum): 541b16049aSZach Atkins """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`""" 551b16049aSZach Atkins __str__ = str.__str__ 561b16049aSZach Atkins __format__ = str.__format__ 571b16049aSZach Atkins TAP: str = 'tap' 581b16049aSZach Atkins JUNIT: str = 'junit' 591b16049aSZach Atkins 601b16049aSZach Atkins 611b16049aSZach Atkinsclass SuiteSpec(ABC): 621b16049aSZach Atkins """Abstract Base Class defining the required interface for running a test suite""" 631b16049aSZach Atkins @abstractmethod 641b16049aSZach Atkins def get_source_path(self, test: str) -> Path: 651b16049aSZach Atkins """Compute path to test source file 661b16049aSZach Atkins 671b16049aSZach Atkins Args: 681b16049aSZach Atkins test (str): Name of test 691b16049aSZach Atkins 701b16049aSZach Atkins Returns: 711b16049aSZach Atkins Path: Path to source file 721b16049aSZach Atkins """ 731b16049aSZach Atkins raise NotImplementedError 741b16049aSZach Atkins 751b16049aSZach Atkins @abstractmethod 761b16049aSZach Atkins def get_run_path(self, test: str) -> Path: 771b16049aSZach Atkins """Compute path to built test executable file 781b16049aSZach Atkins 791b16049aSZach Atkins Args: 801b16049aSZach Atkins test (str): Name of test 811b16049aSZach Atkins 821b16049aSZach Atkins Returns: 831b16049aSZach Atkins Path: Path to test executable 841b16049aSZach Atkins """ 851b16049aSZach Atkins raise NotImplementedError 861b16049aSZach Atkins 871b16049aSZach Atkins @abstractmethod 881b16049aSZach Atkins def get_output_path(self, test: str, output_file: str) -> Path: 891b16049aSZach Atkins """Compute path to expected output file 901b16049aSZach Atkins 911b16049aSZach Atkins Args: 921b16049aSZach Atkins test (str): Name of test 931b16049aSZach Atkins output_file (str): File name of output file 941b16049aSZach Atkins 951b16049aSZach Atkins Returns: 961b16049aSZach Atkins Path: Path to expected output file 971b16049aSZach Atkins """ 981b16049aSZach Atkins raise NotImplementedError 991b16049aSZach Atkins 100c0ad81e5SJeremy L Thompson @property 101c0ad81e5SJeremy L Thompson def cgns_tol(self): 102c0ad81e5SJeremy L Thompson """Absolute tolerance for CGNS diff""" 103c0ad81e5SJeremy L Thompson return getattr(self, '_cgns_tol', 1.0e-12) 10483ebc4c4SJeremy L Thompson 105c0ad81e5SJeremy L Thompson @cgns_tol.setter 106c0ad81e5SJeremy L Thompson def cgns_tol(self, val): 107c0ad81e5SJeremy L Thompson self._cgns_tol = val 10883ebc4c4SJeremy L Thompson 1091b16049aSZach Atkins def post_test_hook(self, test: str, spec: TestSpec) -> None: 1101b16049aSZach Atkins """Function callback ran after each test case 1111b16049aSZach Atkins 1121b16049aSZach Atkins Args: 1131b16049aSZach Atkins test (str): Name of test 1141b16049aSZach Atkins spec (TestSpec): Test case specification 1151b16049aSZach Atkins """ 1161b16049aSZach Atkins pass 1171b16049aSZach Atkins 1181b16049aSZach Atkins def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]: 1191b16049aSZach Atkins """Check if a test case should be skipped prior to running, returning the reason for skipping 1201b16049aSZach Atkins 1211b16049aSZach Atkins Args: 1221b16049aSZach Atkins test (str): Name of test 1231b16049aSZach Atkins spec (TestSpec): Test case specification 1241b16049aSZach Atkins resource (str): libCEED backend 1251b16049aSZach Atkins nproc (int): Number of MPI processes to use when running test case 1261b16049aSZach Atkins 1271b16049aSZach Atkins Returns: 1281b16049aSZach Atkins Optional[str]: Skip reason, or `None` if test case should not be skipped 1291b16049aSZach Atkins """ 1301b16049aSZach Atkins return None 1311b16049aSZach Atkins 1321b16049aSZach Atkins def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]: 1331b16049aSZach Atkins """Check if a test case should be allowed to fail, based on its stderr output 1341b16049aSZach Atkins 1351b16049aSZach Atkins Args: 1361b16049aSZach Atkins test (str): Name of test 1371b16049aSZach Atkins spec (TestSpec): Test case specification 1381b16049aSZach Atkins resource (str): libCEED backend 1391b16049aSZach Atkins stderr (str): Standard error output from test case execution 1401b16049aSZach Atkins 1411b16049aSZach Atkins Returns: 14219868e18SZach Atkins Optional[str]: Skip reason, or `None` if unexpected error 1431b16049aSZach Atkins """ 1441b16049aSZach Atkins return None 1451b16049aSZach Atkins 14678cb100bSJames Wright def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]: 1471b16049aSZach Atkins """Check whether a test case is expected to fail and if it failed expectedly 1481b16049aSZach Atkins 1491b16049aSZach Atkins Args: 1501b16049aSZach Atkins test (str): Name of test 1511b16049aSZach Atkins spec (TestSpec): Test case specification 1521b16049aSZach Atkins resource (str): libCEED backend 1531b16049aSZach Atkins stderr (str): Standard error output from test case execution 1541b16049aSZach Atkins 1551b16049aSZach Atkins Returns: 1561b16049aSZach Atkins tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr` 1571b16049aSZach Atkins """ 1581b16049aSZach Atkins return '', True 1591b16049aSZach Atkins 1601b16049aSZach Atkins def check_allowed_stdout(self, test: str) -> bool: 1611b16049aSZach Atkins """Check whether a test is allowed to print console output 1621b16049aSZach Atkins 1631b16049aSZach Atkins Args: 1641b16049aSZach Atkins test (str): Name of test 1651b16049aSZach Atkins 1661b16049aSZach Atkins Returns: 1671b16049aSZach Atkins bool: True if the test is allowed to print console output 1681b16049aSZach Atkins """ 1691b16049aSZach Atkins return False 1701b16049aSZach Atkins 1711b16049aSZach Atkins 1721b16049aSZach Atkinsdef has_cgnsdiff() -> bool: 1731b16049aSZach Atkins """Check whether `cgnsdiff` is an executable program in the current environment 1741b16049aSZach Atkins 1751b16049aSZach Atkins Returns: 1761b16049aSZach Atkins bool: True if `cgnsdiff` is found 1771b16049aSZach Atkins """ 1781b16049aSZach Atkins my_env: dict = os.environ.copy() 1791b16049aSZach Atkins proc = subprocess.run('cgnsdiff', 1801b16049aSZach Atkins shell=True, 1811b16049aSZach Atkins stdout=subprocess.PIPE, 1821b16049aSZach Atkins stderr=subprocess.PIPE, 1831b16049aSZach Atkins env=my_env) 1841b16049aSZach Atkins return 'not found' not in proc.stderr.decode('utf-8') 1851b16049aSZach Atkins 1861b16049aSZach Atkins 18778cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool: 1881b16049aSZach Atkins """Helper function, checks if any of the substrings are included in the base string 1891b16049aSZach Atkins 1901b16049aSZach Atkins Args: 1911b16049aSZach Atkins base (str): Base string to search in 1928938a869SZach Atkins substrings (List[str]): List of potential substrings 1931b16049aSZach Atkins 1941b16049aSZach Atkins Returns: 1951b16049aSZach Atkins bool: True if any substrings are included in base string 1961b16049aSZach Atkins """ 1971b16049aSZach Atkins return any((sub in base for sub in substrings)) 1981b16049aSZach Atkins 1991b16049aSZach Atkins 20078cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool: 2011b16049aSZach Atkins """Helper function, checks if the base string is prefixed by any of `prefixes` 2021b16049aSZach Atkins 2031b16049aSZach Atkins Args: 2041b16049aSZach Atkins base (str): Base string to search 2058938a869SZach Atkins prefixes (List[str]): List of potential prefixes 2061b16049aSZach Atkins 2071b16049aSZach Atkins Returns: 2081b16049aSZach Atkins bool: True if base string is prefixed by any of the prefixes 2091b16049aSZach Atkins """ 2101b16049aSZach Atkins return any((base.startswith(prefix) for prefix in prefixes)) 2111b16049aSZach Atkins 2121b16049aSZach Atkins 2131b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec: 2141b16049aSZach Atkins """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object 2151b16049aSZach Atkins 2161b16049aSZach Atkins Args: 2171b16049aSZach Atkins line (str): String containing TESTARGS specification and CLI arguments 2181b16049aSZach Atkins 2191b16049aSZach Atkins Returns: 2201b16049aSZach Atkins TestSpec: Parsed specification of test case 2211b16049aSZach Atkins """ 22278cb100bSJames Wright args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip()) 2231b16049aSZach Atkins if args[0] == 'TESTARGS': 2241b16049aSZach Atkins return TestSpec(name='', args=args[1:]) 2251b16049aSZach Atkins raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')] 2261b16049aSZach Atkins # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'} 2271b16049aSZach Atkins test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)]) 228f85e4a7bSJeremy L Thompson name: str = test_args.get('name', '') 22978cb100bSJames Wright constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else [] 2301b16049aSZach Atkins if len(args) > 1: 231f85e4a7bSJeremy L Thompson return TestSpec(name=name, only=constraints, args=args[1:]) 2321b16049aSZach Atkins else: 233f85e4a7bSJeremy L Thompson return TestSpec(name=name, only=constraints) 2341b16049aSZach Atkins 2351b16049aSZach Atkins 23678cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]: 2371b16049aSZach Atkins """Parse all test cases from a given source file 2381b16049aSZach Atkins 2391b16049aSZach Atkins Args: 2401b16049aSZach Atkins source_file (Path): Path to source file 2411b16049aSZach Atkins 2421b16049aSZach Atkins Raises: 2431b16049aSZach Atkins RuntimeError: Errors if source file extension is unsupported 2441b16049aSZach Atkins 2451b16049aSZach Atkins Returns: 2468938a869SZach Atkins List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found 2471b16049aSZach Atkins """ 2481b16049aSZach Atkins comment_str: str = '' 2498c81f8b0SPeter Munch if source_file.suffix in ['.c', '.cc', '.cpp']: 2501b16049aSZach Atkins comment_str = '//' 2511b16049aSZach Atkins elif source_file.suffix in ['.py']: 2521b16049aSZach Atkins comment_str = '#' 2531b16049aSZach Atkins elif source_file.suffix in ['.usr']: 2541b16049aSZach Atkins comment_str = 'C_' 2551b16049aSZach Atkins elif source_file.suffix in ['.f90']: 2561b16049aSZach Atkins comment_str = '! ' 2571b16049aSZach Atkins else: 2581b16049aSZach Atkins raise RuntimeError(f'Unrecognized extension for file: {source_file}') 2591b16049aSZach Atkins 2601b16049aSZach Atkins return [parse_test_line(line.strip(comment_str)) 2611b16049aSZach Atkins for line in source_file.read_text().splitlines() 2621b16049aSZach Atkins if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])] 2631b16049aSZach Atkins 2641b16049aSZach Atkins 2651b16049aSZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2) -> str: 2661b16049aSZach Atkins """Compare CSV results against an expected CSV file with tolerances 2671b16049aSZach Atkins 2681b16049aSZach Atkins Args: 2691b16049aSZach Atkins test_csv (Path): Path to output CSV results 2701b16049aSZach Atkins true_csv (Path): Path to expected CSV results 2711b16049aSZach Atkins zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10. 2721b16049aSZach Atkins rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2. 2731b16049aSZach Atkins 2741b16049aSZach Atkins Returns: 2751b16049aSZach Atkins str: Diff output between result and expected CSVs 2761b16049aSZach Atkins """ 27778cb100bSJames Wright test_lines: List[str] = test_csv.read_text().splitlines() 27878cb100bSJames Wright true_lines: List[str] = true_csv.read_text().splitlines() 27969ef23b6SZach Atkins # Files should not be empty 28069ef23b6SZach Atkins if len(test_lines) == 0: 28169ef23b6SZach Atkins return f'No lines found in test output {test_csv}' 28269ef23b6SZach Atkins if len(true_lines) == 0: 28369ef23b6SZach Atkins return f'No lines found in test source {true_csv}' 2841b16049aSZach Atkins 28569ef23b6SZach Atkins test_reader: csv.DictReader = csv.DictReader(test_lines) 28669ef23b6SZach Atkins true_reader: csv.DictReader = csv.DictReader(true_lines) 28769ef23b6SZach Atkins if test_reader.fieldnames != true_reader.fieldnames: 2881b16049aSZach Atkins return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'], 2891b16049aSZach Atkins tofile='found CSV columns', fromfile='expected CSV columns')) 2901b16049aSZach Atkins 29169ef23b6SZach Atkins if len(test_lines) != len(true_lines): 29269ef23b6SZach Atkins return f'Number of lines in {test_csv} and {true_csv} do not match' 29378cb100bSJames Wright diff_lines: List[str] = list() 29469ef23b6SZach Atkins for test_line, true_line in zip(test_reader, true_reader): 29569ef23b6SZach Atkins for key in test_reader.fieldnames: 29669ef23b6SZach Atkins # Check if the value is numeric 29769ef23b6SZach Atkins try: 29869ef23b6SZach Atkins true_val: float = float(true_line[key]) 29969ef23b6SZach Atkins test_val: float = float(test_line[key]) 3001b16049aSZach Atkins true_zero: bool = abs(true_val) < zero_tol 3011b16049aSZach Atkins test_zero: bool = abs(test_val) < zero_tol 3021b16049aSZach Atkins fail: bool = False 3031b16049aSZach Atkins if true_zero: 3041b16049aSZach Atkins fail = not test_zero 3051b16049aSZach Atkins else: 3061b16049aSZach Atkins fail = not isclose(test_val, true_val, rel_tol=rel_tol) 3071b16049aSZach Atkins if fail: 30869ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}') 30969ef23b6SZach Atkins except ValueError: 31069ef23b6SZach Atkins if test_line[key] != true_line[key]: 31169ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}') 31269ef23b6SZach Atkins 3131b16049aSZach Atkins return '\n'.join(diff_lines) 3141b16049aSZach Atkins 3151b16049aSZach Atkins 31683ebc4c4SJeremy L Thompsondef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float = 1e-12) -> str: 3171b16049aSZach Atkins """Compare CGNS results against an expected CGSN file with tolerance 3181b16049aSZach Atkins 3191b16049aSZach Atkins Args: 3201b16049aSZach Atkins test_cgns (Path): Path to output CGNS file 3211b16049aSZach Atkins true_cgns (Path): Path to expected CGNS file 32283ebc4c4SJeremy L Thompson cgns_tol (float, optional): Tolerance for comparing floating-point values 3231b16049aSZach Atkins 3241b16049aSZach Atkins Returns: 3251b16049aSZach Atkins str: Diff output between result and expected CGNS files 3261b16049aSZach Atkins """ 3271b16049aSZach Atkins my_env: dict = os.environ.copy() 3281b16049aSZach Atkins 32983ebc4c4SJeremy L Thompson run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)] 3301b16049aSZach Atkins proc = subprocess.run(' '.join(run_args), 3311b16049aSZach Atkins shell=True, 3321b16049aSZach Atkins stdout=subprocess.PIPE, 3331b16049aSZach Atkins stderr=subprocess.PIPE, 3341b16049aSZach Atkins env=my_env) 3351b16049aSZach Atkins 3361b16049aSZach Atkins return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8') 3371b16049aSZach Atkins 3381b16049aSZach Atkins 339e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode, 340e17e35bbSJames Wright backend: str, test: str, index: int) -> str: 341e17e35bbSJames Wright output_str = '' 342e17e35bbSJames Wright if mode is RunMode.TAP: 343e17e35bbSJames Wright # print incremental output if TAP mode 344e17e35bbSJames Wright if test_case.is_skipped(): 345e17e35bbSJames Wright output_str += f' ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n' 346e17e35bbSJames Wright elif test_case.is_failure() or test_case.is_error(): 347e17e35bbSJames Wright output_str += f' not ok {index} - {spec.name}, {backend}\n' 348e17e35bbSJames Wright else: 349e17e35bbSJames Wright output_str += f' ok {index} - {spec.name}, {backend}\n' 350e17e35bbSJames Wright output_str += f' ---\n' 351e17e35bbSJames Wright if spec.only: 352e17e35bbSJames Wright output_str += f' only: {",".join(spec.only)}\n' 353e17e35bbSJames Wright output_str += f' args: {test_case.args}\n' 354e17e35bbSJames Wright if test_case.is_error(): 355e17e35bbSJames Wright output_str += f' error: {test_case.errors[0]["message"]}\n' 356e17e35bbSJames Wright if test_case.is_failure(): 357e17e35bbSJames Wright output_str += f' num_failures: {len(test_case.failures)}\n' 358e17e35bbSJames Wright for i, failure in enumerate(test_case.failures): 359e17e35bbSJames Wright output_str += f' failure_{i}: {failure["message"]}\n' 360e17e35bbSJames Wright output_str += f' message: {failure["message"]}\n' 361e17e35bbSJames Wright if failure["output"]: 362e17e35bbSJames Wright out = failure["output"].strip().replace('\n', '\n ') 363e17e35bbSJames Wright output_str += f' output: |\n {out}\n' 364e17e35bbSJames Wright output_str += f' ...\n' 365e17e35bbSJames Wright else: 366e17e35bbSJames Wright # print error or failure information if JUNIT mode 367e17e35bbSJames Wright if test_case.is_error() or test_case.is_failure(): 368e17e35bbSJames Wright output_str += f'Test: {test} {spec.name}\n' 369e17e35bbSJames Wright output_str += f' $ {test_case.args}\n' 370e17e35bbSJames Wright if test_case.is_error(): 371e17e35bbSJames Wright output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()) 372e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()) 373e17e35bbSJames Wright if test_case.is_failure(): 374e17e35bbSJames Wright for failure in test_case.failures: 375e17e35bbSJames Wright output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip()) 376e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip()) 377e17e35bbSJames Wright return output_str 378e17e35bbSJames Wright 379e17e35bbSJames Wright 38019868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str, 38119868e18SZach Atkins mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase: 38219868e18SZach Atkins """Run a single test case and backend combination 3831b16049aSZach Atkins 3841b16049aSZach Atkins Args: 3858938a869SZach Atkins index (int): Index of backend for current spec 38619868e18SZach Atkins test (str): Path to test 38719868e18SZach Atkins spec (TestSpec): Specification of test case 38819868e18SZach Atkins backend (str): CEED backend 38919868e18SZach Atkins mode (RunMode): Output mode 39019868e18SZach Atkins nproc (int): Number of MPI processes to use when running test case 39119868e18SZach Atkins suite_spec (SuiteSpec): Specification of test suite 3921b16049aSZach Atkins 3931b16049aSZach Atkins Returns: 39419868e18SZach Atkins TestCase: Test case result 3951b16049aSZach Atkins """ 3961b16049aSZach Atkins source_path: Path = suite_spec.get_source_path(test) 3978938a869SZach Atkins run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)] 3981b16049aSZach Atkins 3991b16049aSZach Atkins if '{ceed_resource}' in run_args: 40019868e18SZach Atkins run_args[run_args.index('{ceed_resource}')] = backend 4018938a869SZach Atkins for i, arg in enumerate(run_args): 4028938a869SZach Atkins if '{ceed_resource}' in arg: 4038938a869SZach Atkins run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-')) 4041b16049aSZach Atkins if '{nproc}' in run_args: 4051b16049aSZach Atkins run_args[run_args.index('{nproc}')] = f'{nproc}' 4061b16049aSZach Atkins elif nproc > 1 and source_path.suffix != '.py': 4071b16049aSZach Atkins run_args = ['mpiexec', '-n', f'{nproc}', *run_args] 4081b16049aSZach Atkins 4091b16049aSZach Atkins # run test 41019868e18SZach Atkins skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc) 4111b16049aSZach Atkins if skip_reason: 41219868e18SZach Atkins test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 4131b16049aSZach Atkins elapsed_sec=0, 4141b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()), 4151b16049aSZach Atkins stdout='', 4168938a869SZach Atkins stderr='', 4178938a869SZach Atkins category=spec.name,) 4181b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 4191b16049aSZach Atkins else: 4201b16049aSZach Atkins start: float = time.time() 4211b16049aSZach Atkins proc = subprocess.run(' '.join(str(arg) for arg in run_args), 4221b16049aSZach Atkins shell=True, 4231b16049aSZach Atkins stdout=subprocess.PIPE, 4241b16049aSZach Atkins stderr=subprocess.PIPE, 4251b16049aSZach Atkins env=my_env) 4261b16049aSZach Atkins 42719868e18SZach Atkins test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 4281b16049aSZach Atkins classname=source_path.parent, 4291b16049aSZach Atkins elapsed_sec=time.time() - start, 4301b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)), 4311b16049aSZach Atkins stdout=proc.stdout.decode('utf-8'), 4321b16049aSZach Atkins stderr=proc.stderr.decode('utf-8'), 4338938a869SZach Atkins allow_multiple_subelements=True, 4348938a869SZach Atkins category=spec.name,) 43578cb100bSJames Wright ref_csvs: List[Path] = [] 4368938a869SZach Atkins output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg] 43797fab443SJeremy L Thompson if output_files: 4381b16049aSZach Atkins ref_csvs = [suite_spec.get_output_path(test, file.split('ascii:')[-1]) for file in output_files] 43978cb100bSJames Wright ref_cgns: List[Path] = [] 4408938a869SZach Atkins output_files = [arg for arg in run_args if 'cgns:' in arg] 44197fab443SJeremy L Thompson if output_files: 4421b16049aSZach Atkins ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files] 4431b16049aSZach Atkins ref_stdout: Path = suite_spec.get_output_path(test, test + '.out') 4441b16049aSZach Atkins suite_spec.post_test_hook(test, spec) 4451b16049aSZach Atkins 4461b16049aSZach Atkins # check allowed failures 4471b16049aSZach Atkins if not test_case.is_skipped() and test_case.stderr: 44819868e18SZach Atkins skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr) 4491b16049aSZach Atkins if skip_reason: 4501b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 4511b16049aSZach Atkins 4521b16049aSZach Atkins # check required failures 4531b16049aSZach Atkins if not test_case.is_skipped(): 4542fee3251SSebastian Grimberg required_message, did_fail = suite_spec.check_required_failure( 45519868e18SZach Atkins test, spec, backend, test_case.stderr) 4561b16049aSZach Atkins if required_message and did_fail: 4571b16049aSZach Atkins test_case.status = f'fails with required: {required_message}' 4581b16049aSZach Atkins elif required_message: 4591b16049aSZach Atkins test_case.add_failure_info(f'required failure missing: {required_message}') 4601b16049aSZach Atkins 4611b16049aSZach Atkins # classify other results 4621b16049aSZach Atkins if not test_case.is_skipped() and not test_case.status: 4631b16049aSZach Atkins if test_case.stderr: 4641b16049aSZach Atkins test_case.add_failure_info('stderr', test_case.stderr) 4651b16049aSZach Atkins if proc.returncode != 0: 4661b16049aSZach Atkins test_case.add_error_info(f'returncode = {proc.returncode}') 4671b16049aSZach Atkins if ref_stdout.is_file(): 4681b16049aSZach Atkins diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True), 4691b16049aSZach Atkins test_case.stdout.splitlines(keepends=True), 4701b16049aSZach Atkins fromfile=str(ref_stdout), 4711b16049aSZach Atkins tofile='New')) 4721b16049aSZach Atkins if diff: 4731b16049aSZach Atkins test_case.add_failure_info('stdout', output=''.join(diff)) 4741b16049aSZach Atkins elif test_case.stdout and not suite_spec.check_allowed_stdout(test): 4751b16049aSZach Atkins test_case.add_failure_info('stdout', output=test_case.stdout) 4761b16049aSZach Atkins # expected CSV output 4771b16049aSZach Atkins for ref_csv in ref_csvs: 4788938a869SZach Atkins csv_name = ref_csv.name 4798938a869SZach Atkins if not ref_csv.is_file(): 4808938a869SZach Atkins # remove _{ceed_backend} from path name 4818938a869SZach Atkins ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv') 4821b16049aSZach Atkins if not ref_csv.is_file(): 4831b16049aSZach Atkins test_case.add_failure_info('csv', output=f'{ref_csv} not found') 484*ecceccc8SJeremy L Thompson elif not (Path.cwd() / csv_name).is_file(): 485*ecceccc8SJeremy L Thompson test_case.add_failure_info('csv', output=f'{csv_name} not found') 4861b16049aSZach Atkins else: 4878938a869SZach Atkins diff: str = diff_csv(Path.cwd() / csv_name, ref_csv) 4881b16049aSZach Atkins if diff: 4891b16049aSZach Atkins test_case.add_failure_info('csv', output=diff) 4901b16049aSZach Atkins else: 4918938a869SZach Atkins (Path.cwd() / csv_name).unlink() 4921b16049aSZach Atkins # expected CGNS output 4931b16049aSZach Atkins for ref_cgn in ref_cgns: 4948938a869SZach Atkins cgn_name = ref_cgn.name 4958938a869SZach Atkins if not ref_cgn.is_file(): 4968938a869SZach Atkins # remove _{ceed_backend} from path name 4978938a869SZach Atkins ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns') 4981b16049aSZach Atkins if not ref_cgn.is_file(): 4991b16049aSZach Atkins test_case.add_failure_info('cgns', output=f'{ref_cgn} not found') 500*ecceccc8SJeremy L Thompson elif not (Path.cwd() / cgn_name).is_file(): 501*ecceccc8SJeremy L Thompson test_case.add_failure_info('csv', output=f'{cgn_name} not found') 5021b16049aSZach Atkins else: 503c0ad81e5SJeremy L Thompson diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn, cgns_tol=suite_spec.cgns_tol) 5041b16049aSZach Atkins if diff: 5051b16049aSZach Atkins test_case.add_failure_info('cgns', output=diff) 5061b16049aSZach Atkins else: 5078938a869SZach Atkins (Path.cwd() / cgn_name).unlink() 5081b16049aSZach Atkins 5091b16049aSZach Atkins # store result 5101b16049aSZach Atkins test_case.args = ' '.join(str(arg) for arg in run_args) 511e17e35bbSJames Wright output_str = test_case_output_string(test_case, spec, mode, backend, test, index) 51219868e18SZach Atkins 51319868e18SZach Atkins return test_case, output_str 51419868e18SZach Atkins 51519868e18SZach Atkins 51619868e18SZach Atkinsdef init_process(): 51719868e18SZach Atkins """Initialize multiprocessing process""" 51819868e18SZach Atkins # set up error handler 51919868e18SZach Atkins global my_env 52019868e18SZach Atkins my_env = os.environ.copy() 52119868e18SZach Atkins my_env['CEED_ERROR_HANDLER'] = 'exit' 52219868e18SZach Atkins 52319868e18SZach Atkins 52478cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int, 52519868e18SZach Atkins suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite: 52619868e18SZach Atkins """Run all test cases for `test` with each of the provided `ceed_backends` 52719868e18SZach Atkins 52819868e18SZach Atkins Args: 52919868e18SZach Atkins test (str): Name of test 5308938a869SZach Atkins ceed_backends (List[str]): List of libCEED backends 53119868e18SZach Atkins mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT` 53219868e18SZach Atkins nproc (int): Number of MPI processes to use when running each test case 53319868e18SZach Atkins suite_spec (SuiteSpec): Object defining required methods for running tests 53419868e18SZach Atkins pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1. 53519868e18SZach Atkins 53619868e18SZach Atkins Returns: 53719868e18SZach Atkins TestSuite: JUnit `TestSuite` containing results of all test cases 53819868e18SZach Atkins """ 53978cb100bSJames Wright test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test)) 54019868e18SZach Atkins if mode is RunMode.TAP: 5418938a869SZach Atkins print('TAP version 13') 5428938a869SZach Atkins print(f'1..{len(test_specs)}') 54319868e18SZach Atkins 54419868e18SZach Atkins with mp.Pool(processes=pool_size, initializer=init_process) as pool: 5458938a869SZach Atkins async_outputs: List[List[mp.AsyncResult]] = [ 5468938a869SZach Atkins [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec)) 5478938a869SZach Atkins for (i, backend) in enumerate(ceed_backends, start=1)] 5488938a869SZach Atkins for spec in test_specs 5498938a869SZach Atkins ] 55019868e18SZach Atkins 55119868e18SZach Atkins test_cases = [] 5528938a869SZach Atkins for (i, subtest) in enumerate(async_outputs, start=1): 5538938a869SZach Atkins is_new_subtest = True 5548938a869SZach Atkins subtest_ok = True 5558938a869SZach Atkins for async_output in subtest: 55619868e18SZach Atkins test_case, print_output = async_output.get() 55719868e18SZach Atkins test_cases.append(test_case) 5588938a869SZach Atkins if is_new_subtest and mode == RunMode.TAP: 5598938a869SZach Atkins is_new_subtest = False 5608938a869SZach Atkins print(f'# Subtest: {test_case.category}') 5618938a869SZach Atkins print(f' 1..{len(ceed_backends)}') 56219868e18SZach Atkins print(print_output, end='') 5638938a869SZach Atkins if test_case.is_failure() or test_case.is_error(): 5648938a869SZach Atkins subtest_ok = False 5658938a869SZach Atkins if mode == RunMode.TAP: 5668938a869SZach Atkins print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}') 5671b16049aSZach Atkins 5681b16049aSZach Atkins return TestSuite(test, test_cases) 5691b16049aSZach Atkins 5701b16049aSZach Atkins 5711b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None: 5721b16049aSZach Atkins """Write a JUnit XML file containing the results of a `TestSuite` 5731b16049aSZach Atkins 5741b16049aSZach Atkins Args: 5751b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to write 5761b16049aSZach Atkins output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit` 5771b16049aSZach Atkins batch (str): Name of JUnit batch, defaults to empty string 5781b16049aSZach Atkins """ 5791b16049aSZach Atkins output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit') 5801b16049aSZach Atkins output_file.write_text(to_xml_report_string([test_suite])) 5811b16049aSZach Atkins 5821b16049aSZach Atkins 5831b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool: 5841b16049aSZach Atkins """Check whether any test cases in a `TestSuite` failed 5851b16049aSZach Atkins 5861b16049aSZach Atkins Args: 5871b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to check 5881b16049aSZach Atkins 5891b16049aSZach Atkins Returns: 5901b16049aSZach Atkins bool: True if any test cases failed 5911b16049aSZach Atkins """ 5921b16049aSZach Atkins return any(c.is_failure() or c.is_error() for c in test_suite.test_cases) 593