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 16*12235d7fSJames Wrightfrom typing import Optional, Tuple, List, Callable 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 109*12235d7fSJames Wright @property 110*12235d7fSJames Wright def diff_csv_kwargs(self): 111*12235d7fSJames Wright """Keyword arguments to be passed to diff_csv()""" 112*12235d7fSJames Wright return getattr(self, '_diff_csv_kwargs', {}) 113*12235d7fSJames Wright 114*12235d7fSJames Wright @diff_csv_kwargs.setter 115*12235d7fSJames Wright def diff_csv_kwargs(self, val): 116*12235d7fSJames Wright self._diff_csv_kwargs = val 117*12235d7fSJames Wright 1181b16049aSZach Atkins def post_test_hook(self, test: str, spec: TestSpec) -> None: 1191b16049aSZach Atkins """Function callback ran after each test case 1201b16049aSZach Atkins 1211b16049aSZach Atkins Args: 1221b16049aSZach Atkins test (str): Name of test 1231b16049aSZach Atkins spec (TestSpec): Test case specification 1241b16049aSZach Atkins """ 1251b16049aSZach Atkins pass 1261b16049aSZach Atkins 1271b16049aSZach Atkins def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]: 1281b16049aSZach Atkins """Check if a test case should be skipped prior to running, returning the reason for skipping 1291b16049aSZach Atkins 1301b16049aSZach Atkins Args: 1311b16049aSZach Atkins test (str): Name of test 1321b16049aSZach Atkins spec (TestSpec): Test case specification 1331b16049aSZach Atkins resource (str): libCEED backend 1341b16049aSZach Atkins nproc (int): Number of MPI processes to use when running test case 1351b16049aSZach Atkins 1361b16049aSZach Atkins Returns: 1371b16049aSZach Atkins Optional[str]: Skip reason, or `None` if test case should not be skipped 1381b16049aSZach Atkins """ 1391b16049aSZach Atkins return None 1401b16049aSZach Atkins 1411b16049aSZach Atkins def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]: 1421b16049aSZach Atkins """Check if a test case should be allowed to fail, based on its stderr output 1431b16049aSZach Atkins 1441b16049aSZach Atkins Args: 1451b16049aSZach Atkins test (str): Name of test 1461b16049aSZach Atkins spec (TestSpec): Test case specification 1471b16049aSZach Atkins resource (str): libCEED backend 1481b16049aSZach Atkins stderr (str): Standard error output from test case execution 1491b16049aSZach Atkins 1501b16049aSZach Atkins Returns: 15119868e18SZach Atkins Optional[str]: Skip reason, or `None` if unexpected error 1521b16049aSZach Atkins """ 1531b16049aSZach Atkins return None 1541b16049aSZach Atkins 15578cb100bSJames Wright def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]: 1561b16049aSZach Atkins """Check whether a test case is expected to fail and if it failed expectedly 1571b16049aSZach Atkins 1581b16049aSZach Atkins Args: 1591b16049aSZach Atkins test (str): Name of test 1601b16049aSZach Atkins spec (TestSpec): Test case specification 1611b16049aSZach Atkins resource (str): libCEED backend 1621b16049aSZach Atkins stderr (str): Standard error output from test case execution 1631b16049aSZach Atkins 1641b16049aSZach Atkins Returns: 1651b16049aSZach Atkins tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr` 1661b16049aSZach Atkins """ 1671b16049aSZach Atkins return '', True 1681b16049aSZach Atkins 1691b16049aSZach Atkins def check_allowed_stdout(self, test: str) -> bool: 1701b16049aSZach Atkins """Check whether a test is allowed to print console output 1711b16049aSZach Atkins 1721b16049aSZach Atkins Args: 1731b16049aSZach Atkins test (str): Name of test 1741b16049aSZach Atkins 1751b16049aSZach Atkins Returns: 1761b16049aSZach Atkins bool: True if the test is allowed to print console output 1771b16049aSZach Atkins """ 1781b16049aSZach Atkins return False 1791b16049aSZach Atkins 1801b16049aSZach Atkins 1811b16049aSZach Atkinsdef has_cgnsdiff() -> bool: 1821b16049aSZach Atkins """Check whether `cgnsdiff` is an executable program in the current environment 1831b16049aSZach Atkins 1841b16049aSZach Atkins Returns: 1851b16049aSZach Atkins bool: True if `cgnsdiff` is found 1861b16049aSZach Atkins """ 1871b16049aSZach Atkins my_env: dict = os.environ.copy() 1881b16049aSZach Atkins proc = subprocess.run('cgnsdiff', 1891b16049aSZach Atkins shell=True, 1901b16049aSZach Atkins stdout=subprocess.PIPE, 1911b16049aSZach Atkins stderr=subprocess.PIPE, 1921b16049aSZach Atkins env=my_env) 1931b16049aSZach Atkins return 'not found' not in proc.stderr.decode('utf-8') 1941b16049aSZach Atkins 1951b16049aSZach Atkins 19678cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool: 1971b16049aSZach Atkins """Helper function, checks if any of the substrings are included in the base string 1981b16049aSZach Atkins 1991b16049aSZach Atkins Args: 2001b16049aSZach Atkins base (str): Base string to search in 2018938a869SZach Atkins substrings (List[str]): List of potential substrings 2021b16049aSZach Atkins 2031b16049aSZach Atkins Returns: 2041b16049aSZach Atkins bool: True if any substrings are included in base string 2051b16049aSZach Atkins """ 2061b16049aSZach Atkins return any((sub in base for sub in substrings)) 2071b16049aSZach Atkins 2081b16049aSZach Atkins 20978cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool: 2101b16049aSZach Atkins """Helper function, checks if the base string is prefixed by any of `prefixes` 2111b16049aSZach Atkins 2121b16049aSZach Atkins Args: 2131b16049aSZach Atkins base (str): Base string to search 2148938a869SZach Atkins prefixes (List[str]): List of potential prefixes 2151b16049aSZach Atkins 2161b16049aSZach Atkins Returns: 2171b16049aSZach Atkins bool: True if base string is prefixed by any of the prefixes 2181b16049aSZach Atkins """ 2191b16049aSZach Atkins return any((base.startswith(prefix) for prefix in prefixes)) 2201b16049aSZach Atkins 2211b16049aSZach Atkins 2221b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec: 2231b16049aSZach Atkins """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object 2241b16049aSZach Atkins 2251b16049aSZach Atkins Args: 2261b16049aSZach Atkins line (str): String containing TESTARGS specification and CLI arguments 2271b16049aSZach Atkins 2281b16049aSZach Atkins Returns: 2291b16049aSZach Atkins TestSpec: Parsed specification of test case 2301b16049aSZach Atkins """ 23178cb100bSJames Wright args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip()) 2321b16049aSZach Atkins if args[0] == 'TESTARGS': 2331b16049aSZach Atkins return TestSpec(name='', args=args[1:]) 2341b16049aSZach Atkins raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')] 2351b16049aSZach Atkins # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'} 2361b16049aSZach Atkins test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)]) 237f85e4a7bSJeremy L Thompson name: str = test_args.get('name', '') 23878cb100bSJames Wright constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else [] 2391b16049aSZach Atkins if len(args) > 1: 240f85e4a7bSJeremy L Thompson return TestSpec(name=name, only=constraints, args=args[1:]) 2411b16049aSZach Atkins else: 242f85e4a7bSJeremy L Thompson return TestSpec(name=name, only=constraints) 2431b16049aSZach Atkins 2441b16049aSZach Atkins 24578cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]: 2461b16049aSZach Atkins """Parse all test cases from a given source file 2471b16049aSZach Atkins 2481b16049aSZach Atkins Args: 2491b16049aSZach Atkins source_file (Path): Path to source file 2501b16049aSZach Atkins 2511b16049aSZach Atkins Raises: 2521b16049aSZach Atkins RuntimeError: Errors if source file extension is unsupported 2531b16049aSZach Atkins 2541b16049aSZach Atkins Returns: 2558938a869SZach Atkins List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found 2561b16049aSZach Atkins """ 2571b16049aSZach Atkins comment_str: str = '' 2588c81f8b0SPeter Munch if source_file.suffix in ['.c', '.cc', '.cpp']: 2591b16049aSZach Atkins comment_str = '//' 2601b16049aSZach Atkins elif source_file.suffix in ['.py']: 2611b16049aSZach Atkins comment_str = '#' 2621b16049aSZach Atkins elif source_file.suffix in ['.usr']: 2631b16049aSZach Atkins comment_str = 'C_' 2641b16049aSZach Atkins elif source_file.suffix in ['.f90']: 2651b16049aSZach Atkins comment_str = '! ' 2661b16049aSZach Atkins else: 2671b16049aSZach Atkins raise RuntimeError(f'Unrecognized extension for file: {source_file}') 2681b16049aSZach Atkins 2691b16049aSZach Atkins return [parse_test_line(line.strip(comment_str)) 2701b16049aSZach Atkins for line in source_file.read_text().splitlines() 2711b16049aSZach Atkins if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])] 2721b16049aSZach Atkins 2731b16049aSZach Atkins 274*12235d7fSJames Wrightdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2, 275*12235d7fSJames Wright comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str: 2761b16049aSZach Atkins """Compare CSV results against an expected CSV file with tolerances 2771b16049aSZach Atkins 2781b16049aSZach Atkins Args: 2791b16049aSZach Atkins test_csv (Path): Path to output CSV results 2801b16049aSZach Atkins true_csv (Path): Path to expected CSV results 2811b16049aSZach Atkins zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10. 2821b16049aSZach Atkins rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2. 283*12235d7fSJames Wright comment_str (str, optional): String to denoting commented line 284*12235d7fSJames Wright comment_func (Callable, optional): Function to determine if test and true line are different 2851b16049aSZach Atkins 2861b16049aSZach Atkins Returns: 2871b16049aSZach Atkins str: Diff output between result and expected CSVs 2881b16049aSZach Atkins """ 28978cb100bSJames Wright test_lines: List[str] = test_csv.read_text().splitlines() 29078cb100bSJames Wright true_lines: List[str] = true_csv.read_text().splitlines() 29169ef23b6SZach Atkins # Files should not be empty 29269ef23b6SZach Atkins if len(test_lines) == 0: 29369ef23b6SZach Atkins return f'No lines found in test output {test_csv}' 29469ef23b6SZach Atkins if len(true_lines) == 0: 29569ef23b6SZach Atkins return f'No lines found in test source {true_csv}' 296*12235d7fSJames Wright if len(test_lines) != len(true_lines): 297*12235d7fSJames Wright return f'Number of lines in {test_csv} and {true_csv} do not match' 298*12235d7fSJames Wright 299*12235d7fSJames Wright # Process commented lines 300*12235d7fSJames Wright uncommented_lines: List[int] = [] 301*12235d7fSJames Wright for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)): 302*12235d7fSJames Wright if test_line[0] == comment_str and true_line[0] == comment_str: 303*12235d7fSJames Wright if comment_func: 304*12235d7fSJames Wright output = comment_func(test_line, true_line) 305*12235d7fSJames Wright if output: 306*12235d7fSJames Wright return output 307*12235d7fSJames Wright elif test_line[0] == comment_str and true_line[0] != comment_str: 308*12235d7fSJames Wright return f'Commented line found in {test_csv} at line {n} but not in {true_csv}' 309*12235d7fSJames Wright elif test_line[0] != comment_str and true_line[0] == comment_str: 310*12235d7fSJames Wright return f'Commented line found in {true_csv} at line {n} but not in {test_csv}' 311*12235d7fSJames Wright else: 312*12235d7fSJames Wright uncommented_lines.append(n) 313*12235d7fSJames Wright 314*12235d7fSJames Wright # Remove commented lines 315*12235d7fSJames Wright test_lines = [test_lines[line] for line in uncommented_lines] 316*12235d7fSJames Wright true_lines = [true_lines[line] for line in uncommented_lines] 3171b16049aSZach Atkins 31869ef23b6SZach Atkins test_reader: csv.DictReader = csv.DictReader(test_lines) 31969ef23b6SZach Atkins true_reader: csv.DictReader = csv.DictReader(true_lines) 32069ef23b6SZach Atkins if test_reader.fieldnames != true_reader.fieldnames: 3211b16049aSZach Atkins return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'], 3221b16049aSZach Atkins tofile='found CSV columns', fromfile='expected CSV columns')) 3231b16049aSZach Atkins 32478cb100bSJames Wright diff_lines: List[str] = list() 32569ef23b6SZach Atkins for test_line, true_line in zip(test_reader, true_reader): 32669ef23b6SZach Atkins for key in test_reader.fieldnames: 32769ef23b6SZach Atkins # Check if the value is numeric 32869ef23b6SZach Atkins try: 32969ef23b6SZach Atkins true_val: float = float(true_line[key]) 33069ef23b6SZach Atkins test_val: float = float(test_line[key]) 3311b16049aSZach Atkins true_zero: bool = abs(true_val) < zero_tol 3321b16049aSZach Atkins test_zero: bool = abs(test_val) < zero_tol 3331b16049aSZach Atkins fail: bool = False 3341b16049aSZach Atkins if true_zero: 3351b16049aSZach Atkins fail = not test_zero 3361b16049aSZach Atkins else: 3371b16049aSZach Atkins fail = not isclose(test_val, true_val, rel_tol=rel_tol) 3381b16049aSZach Atkins if fail: 33969ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}') 34069ef23b6SZach Atkins except ValueError: 34169ef23b6SZach Atkins if test_line[key] != true_line[key]: 34269ef23b6SZach Atkins diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}') 34369ef23b6SZach Atkins 3441b16049aSZach Atkins return '\n'.join(diff_lines) 3451b16049aSZach Atkins 3461b16049aSZach Atkins 34783ebc4c4SJeremy L Thompsondef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float = 1e-12) -> str: 3481b16049aSZach Atkins """Compare CGNS results against an expected CGSN file with tolerance 3491b16049aSZach Atkins 3501b16049aSZach Atkins Args: 3511b16049aSZach Atkins test_cgns (Path): Path to output CGNS file 3521b16049aSZach Atkins true_cgns (Path): Path to expected CGNS file 35383ebc4c4SJeremy L Thompson cgns_tol (float, optional): Tolerance for comparing floating-point values 3541b16049aSZach Atkins 3551b16049aSZach Atkins Returns: 3561b16049aSZach Atkins str: Diff output between result and expected CGNS files 3571b16049aSZach Atkins """ 3581b16049aSZach Atkins my_env: dict = os.environ.copy() 3591b16049aSZach Atkins 36083ebc4c4SJeremy L Thompson run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)] 3611b16049aSZach Atkins proc = subprocess.run(' '.join(run_args), 3621b16049aSZach Atkins shell=True, 3631b16049aSZach Atkins stdout=subprocess.PIPE, 3641b16049aSZach Atkins stderr=subprocess.PIPE, 3651b16049aSZach Atkins env=my_env) 3661b16049aSZach Atkins 3671b16049aSZach Atkins return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8') 3681b16049aSZach Atkins 3691b16049aSZach Atkins 370e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode, 371e17e35bbSJames Wright backend: str, test: str, index: int) -> str: 372e17e35bbSJames Wright output_str = '' 373e17e35bbSJames Wright if mode is RunMode.TAP: 374e17e35bbSJames Wright # print incremental output if TAP mode 375e17e35bbSJames Wright if test_case.is_skipped(): 376e17e35bbSJames Wright output_str += f' ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n' 377e17e35bbSJames Wright elif test_case.is_failure() or test_case.is_error(): 378e17e35bbSJames Wright output_str += f' not ok {index} - {spec.name}, {backend}\n' 379e17e35bbSJames Wright else: 380e17e35bbSJames Wright output_str += f' ok {index} - {spec.name}, {backend}\n' 381e17e35bbSJames Wright output_str += f' ---\n' 382e17e35bbSJames Wright if spec.only: 383e17e35bbSJames Wright output_str += f' only: {",".join(spec.only)}\n' 384e17e35bbSJames Wright output_str += f' args: {test_case.args}\n' 385e17e35bbSJames Wright if test_case.is_error(): 386e17e35bbSJames Wright output_str += f' error: {test_case.errors[0]["message"]}\n' 387e17e35bbSJames Wright if test_case.is_failure(): 388e17e35bbSJames Wright output_str += f' num_failures: {len(test_case.failures)}\n' 389e17e35bbSJames Wright for i, failure in enumerate(test_case.failures): 390e17e35bbSJames Wright output_str += f' failure_{i}: {failure["message"]}\n' 391e17e35bbSJames Wright output_str += f' message: {failure["message"]}\n' 392e17e35bbSJames Wright if failure["output"]: 393e17e35bbSJames Wright out = failure["output"].strip().replace('\n', '\n ') 394e17e35bbSJames Wright output_str += f' output: |\n {out}\n' 395e17e35bbSJames Wright output_str += f' ...\n' 396e17e35bbSJames Wright else: 397e17e35bbSJames Wright # print error or failure information if JUNIT mode 398e17e35bbSJames Wright if test_case.is_error() or test_case.is_failure(): 399e17e35bbSJames Wright output_str += f'Test: {test} {spec.name}\n' 400e17e35bbSJames Wright output_str += f' $ {test_case.args}\n' 401e17e35bbSJames Wright if test_case.is_error(): 402e17e35bbSJames Wright output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()) 403e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()) 404e17e35bbSJames Wright if test_case.is_failure(): 405e17e35bbSJames Wright for failure in test_case.failures: 406e17e35bbSJames Wright output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip()) 407e17e35bbSJames Wright output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip()) 408e17e35bbSJames Wright return output_str 409e17e35bbSJames Wright 410e17e35bbSJames Wright 41119868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str, 41219868e18SZach Atkins mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase: 41319868e18SZach Atkins """Run a single test case and backend combination 4141b16049aSZach Atkins 4151b16049aSZach Atkins Args: 4168938a869SZach Atkins index (int): Index of backend for current spec 41719868e18SZach Atkins test (str): Path to test 41819868e18SZach Atkins spec (TestSpec): Specification of test case 41919868e18SZach Atkins backend (str): CEED backend 42019868e18SZach Atkins mode (RunMode): Output mode 42119868e18SZach Atkins nproc (int): Number of MPI processes to use when running test case 42219868e18SZach Atkins suite_spec (SuiteSpec): Specification of test suite 4231b16049aSZach Atkins 4241b16049aSZach Atkins Returns: 42519868e18SZach Atkins TestCase: Test case result 4261b16049aSZach Atkins """ 4271b16049aSZach Atkins source_path: Path = suite_spec.get_source_path(test) 4288938a869SZach Atkins run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)] 4291b16049aSZach Atkins 4301b16049aSZach Atkins if '{ceed_resource}' in run_args: 43119868e18SZach Atkins run_args[run_args.index('{ceed_resource}')] = backend 4328938a869SZach Atkins for i, arg in enumerate(run_args): 4338938a869SZach Atkins if '{ceed_resource}' in arg: 4348938a869SZach Atkins run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-')) 4351b16049aSZach Atkins if '{nproc}' in run_args: 4361b16049aSZach Atkins run_args[run_args.index('{nproc}')] = f'{nproc}' 4371b16049aSZach Atkins elif nproc > 1 and source_path.suffix != '.py': 4381b16049aSZach Atkins run_args = ['mpiexec', '-n', f'{nproc}', *run_args] 4391b16049aSZach Atkins 4401b16049aSZach Atkins # run test 44119868e18SZach Atkins skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc) 4421b16049aSZach Atkins if skip_reason: 44319868e18SZach Atkins test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 4441b16049aSZach Atkins elapsed_sec=0, 4451b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()), 4461b16049aSZach Atkins stdout='', 4478938a869SZach Atkins stderr='', 4488938a869SZach Atkins category=spec.name,) 4491b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 4501b16049aSZach Atkins else: 4511b16049aSZach Atkins start: float = time.time() 4521b16049aSZach Atkins proc = subprocess.run(' '.join(str(arg) for arg in run_args), 4531b16049aSZach Atkins shell=True, 4541b16049aSZach Atkins stdout=subprocess.PIPE, 4551b16049aSZach Atkins stderr=subprocess.PIPE, 4561b16049aSZach Atkins env=my_env) 4571b16049aSZach Atkins 45819868e18SZach Atkins test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 4591b16049aSZach Atkins classname=source_path.parent, 4601b16049aSZach Atkins elapsed_sec=time.time() - start, 4611b16049aSZach Atkins timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)), 4621b16049aSZach Atkins stdout=proc.stdout.decode('utf-8'), 4631b16049aSZach Atkins stderr=proc.stderr.decode('utf-8'), 4648938a869SZach Atkins allow_multiple_subelements=True, 4658938a869SZach Atkins category=spec.name,) 46678cb100bSJames Wright ref_csvs: List[Path] = [] 4678938a869SZach Atkins output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg] 46897fab443SJeremy L Thompson if output_files: 469*12235d7fSJames Wright ref_csvs = [suite_spec.get_output_path(test, file.split(':')[1]) for file in output_files] 47078cb100bSJames Wright ref_cgns: List[Path] = [] 4718938a869SZach Atkins output_files = [arg for arg in run_args if 'cgns:' in arg] 47297fab443SJeremy L Thompson if output_files: 4731b16049aSZach Atkins ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files] 4741b16049aSZach Atkins ref_stdout: Path = suite_spec.get_output_path(test, test + '.out') 4751b16049aSZach Atkins suite_spec.post_test_hook(test, spec) 4761b16049aSZach Atkins 4771b16049aSZach Atkins # check allowed failures 4781b16049aSZach Atkins if not test_case.is_skipped() and test_case.stderr: 47919868e18SZach Atkins skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr) 4801b16049aSZach Atkins if skip_reason: 4811b16049aSZach Atkins test_case.add_skipped_info(skip_reason) 4821b16049aSZach Atkins 4831b16049aSZach Atkins # check required failures 4841b16049aSZach Atkins if not test_case.is_skipped(): 4852fee3251SSebastian Grimberg required_message, did_fail = suite_spec.check_required_failure( 48619868e18SZach Atkins test, spec, backend, test_case.stderr) 4871b16049aSZach Atkins if required_message and did_fail: 4881b16049aSZach Atkins test_case.status = f'fails with required: {required_message}' 4891b16049aSZach Atkins elif required_message: 4901b16049aSZach Atkins test_case.add_failure_info(f'required failure missing: {required_message}') 4911b16049aSZach Atkins 4921b16049aSZach Atkins # classify other results 4931b16049aSZach Atkins if not test_case.is_skipped() and not test_case.status: 4941b16049aSZach Atkins if test_case.stderr: 4951b16049aSZach Atkins test_case.add_failure_info('stderr', test_case.stderr) 4961b16049aSZach Atkins if proc.returncode != 0: 4971b16049aSZach Atkins test_case.add_error_info(f'returncode = {proc.returncode}') 4981b16049aSZach Atkins if ref_stdout.is_file(): 4991b16049aSZach Atkins diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True), 5001b16049aSZach Atkins test_case.stdout.splitlines(keepends=True), 5011b16049aSZach Atkins fromfile=str(ref_stdout), 5021b16049aSZach Atkins tofile='New')) 5031b16049aSZach Atkins if diff: 5041b16049aSZach Atkins test_case.add_failure_info('stdout', output=''.join(diff)) 5051b16049aSZach Atkins elif test_case.stdout and not suite_spec.check_allowed_stdout(test): 5061b16049aSZach Atkins test_case.add_failure_info('stdout', output=test_case.stdout) 5071b16049aSZach Atkins # expected CSV output 5081b16049aSZach Atkins for ref_csv in ref_csvs: 5098938a869SZach Atkins csv_name = ref_csv.name 5108938a869SZach Atkins if not ref_csv.is_file(): 5118938a869SZach Atkins # remove _{ceed_backend} from path name 5128938a869SZach Atkins ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv') 5131b16049aSZach Atkins if not ref_csv.is_file(): 5141b16049aSZach Atkins test_case.add_failure_info('csv', output=f'{ref_csv} not found') 515ecceccc8SJeremy L Thompson elif not (Path.cwd() / csv_name).is_file(): 516ecceccc8SJeremy L Thompson test_case.add_failure_info('csv', output=f'{csv_name} not found') 5171b16049aSZach Atkins else: 518*12235d7fSJames Wright diff: str = diff_csv(Path.cwd() / csv_name, ref_csv, **suite_spec.diff_csv_kwargs) 5191b16049aSZach Atkins if diff: 5201b16049aSZach Atkins test_case.add_failure_info('csv', output=diff) 5211b16049aSZach Atkins else: 5228938a869SZach Atkins (Path.cwd() / csv_name).unlink() 5231b16049aSZach Atkins # expected CGNS output 5241b16049aSZach Atkins for ref_cgn in ref_cgns: 5258938a869SZach Atkins cgn_name = ref_cgn.name 5268938a869SZach Atkins if not ref_cgn.is_file(): 5278938a869SZach Atkins # remove _{ceed_backend} from path name 5288938a869SZach Atkins ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns') 5291b16049aSZach Atkins if not ref_cgn.is_file(): 5301b16049aSZach Atkins test_case.add_failure_info('cgns', output=f'{ref_cgn} not found') 531ecceccc8SJeremy L Thompson elif not (Path.cwd() / cgn_name).is_file(): 532ecceccc8SJeremy L Thompson test_case.add_failure_info('csv', output=f'{cgn_name} not found') 5331b16049aSZach Atkins else: 534c0ad81e5SJeremy L Thompson diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn, cgns_tol=suite_spec.cgns_tol) 5351b16049aSZach Atkins if diff: 5361b16049aSZach Atkins test_case.add_failure_info('cgns', output=diff) 5371b16049aSZach Atkins else: 5388938a869SZach Atkins (Path.cwd() / cgn_name).unlink() 5391b16049aSZach Atkins 5401b16049aSZach Atkins # store result 5411b16049aSZach Atkins test_case.args = ' '.join(str(arg) for arg in run_args) 542e17e35bbSJames Wright output_str = test_case_output_string(test_case, spec, mode, backend, test, index) 54319868e18SZach Atkins 54419868e18SZach Atkins return test_case, output_str 54519868e18SZach Atkins 54619868e18SZach Atkins 54719868e18SZach Atkinsdef init_process(): 54819868e18SZach Atkins """Initialize multiprocessing process""" 54919868e18SZach Atkins # set up error handler 55019868e18SZach Atkins global my_env 55119868e18SZach Atkins my_env = os.environ.copy() 55219868e18SZach Atkins my_env['CEED_ERROR_HANDLER'] = 'exit' 55319868e18SZach Atkins 55419868e18SZach Atkins 55578cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int, 55619868e18SZach Atkins suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite: 55719868e18SZach Atkins """Run all test cases for `test` with each of the provided `ceed_backends` 55819868e18SZach Atkins 55919868e18SZach Atkins Args: 56019868e18SZach Atkins test (str): Name of test 5618938a869SZach Atkins ceed_backends (List[str]): List of libCEED backends 56219868e18SZach Atkins mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT` 56319868e18SZach Atkins nproc (int): Number of MPI processes to use when running each test case 56419868e18SZach Atkins suite_spec (SuiteSpec): Object defining required methods for running tests 56519868e18SZach Atkins pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1. 56619868e18SZach Atkins 56719868e18SZach Atkins Returns: 56819868e18SZach Atkins TestSuite: JUnit `TestSuite` containing results of all test cases 56919868e18SZach Atkins """ 57078cb100bSJames Wright test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test)) 57119868e18SZach Atkins if mode is RunMode.TAP: 5728938a869SZach Atkins print('TAP version 13') 5738938a869SZach Atkins print(f'1..{len(test_specs)}') 57419868e18SZach Atkins 57519868e18SZach Atkins with mp.Pool(processes=pool_size, initializer=init_process) as pool: 5768938a869SZach Atkins async_outputs: List[List[mp.AsyncResult]] = [ 5778938a869SZach Atkins [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec)) 5788938a869SZach Atkins for (i, backend) in enumerate(ceed_backends, start=1)] 5798938a869SZach Atkins for spec in test_specs 5808938a869SZach Atkins ] 58119868e18SZach Atkins 58219868e18SZach Atkins test_cases = [] 5838938a869SZach Atkins for (i, subtest) in enumerate(async_outputs, start=1): 5848938a869SZach Atkins is_new_subtest = True 5858938a869SZach Atkins subtest_ok = True 5868938a869SZach Atkins for async_output in subtest: 58719868e18SZach Atkins test_case, print_output = async_output.get() 58819868e18SZach Atkins test_cases.append(test_case) 5898938a869SZach Atkins if is_new_subtest and mode == RunMode.TAP: 5908938a869SZach Atkins is_new_subtest = False 5918938a869SZach Atkins print(f'# Subtest: {test_case.category}') 5928938a869SZach Atkins print(f' 1..{len(ceed_backends)}') 59319868e18SZach Atkins print(print_output, end='') 5948938a869SZach Atkins if test_case.is_failure() or test_case.is_error(): 5958938a869SZach Atkins subtest_ok = False 5968938a869SZach Atkins if mode == RunMode.TAP: 5978938a869SZach Atkins print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}') 5981b16049aSZach Atkins 5991b16049aSZach Atkins return TestSuite(test, test_cases) 6001b16049aSZach Atkins 6011b16049aSZach Atkins 6021b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None: 6031b16049aSZach Atkins """Write a JUnit XML file containing the results of a `TestSuite` 6041b16049aSZach Atkins 6051b16049aSZach Atkins Args: 6061b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to write 6071b16049aSZach Atkins output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit` 6081b16049aSZach Atkins batch (str): Name of JUnit batch, defaults to empty string 6091b16049aSZach Atkins """ 6101b16049aSZach Atkins output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit') 6111b16049aSZach Atkins output_file.write_text(to_xml_report_string([test_suite])) 6121b16049aSZach Atkins 6131b16049aSZach Atkins 6141b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool: 6151b16049aSZach Atkins """Check whether any test cases in a `TestSuite` failed 6161b16049aSZach Atkins 6171b16049aSZach Atkins Args: 6181b16049aSZach Atkins test_suite (TestSuite): JUnit `TestSuite` to check 6191b16049aSZach Atkins 6201b16049aSZach Atkins Returns: 6211b16049aSZach Atkins bool: True if any test cases failed 6221b16049aSZach Atkins """ 6231b16049aSZach Atkins return any(c.is_failure() or c.is_error() for c in test_suite.test_cases) 624