10006be33SJames Wrightfrom abc import ABC, abstractmethod 2e45c6f40SZach Atkinsfrom collections.abc import Iterable 30006be33SJames Wrightimport argparse 40006be33SJames Wrightimport csv 5e45c6f40SZach Atkinsfrom dataclasses import dataclass, field, fields 60006be33SJames Wrightimport difflib 70006be33SJames Wrightfrom enum import Enum 80006be33SJames Wrightfrom math import isclose 90006be33SJames Wrightimport os 100006be33SJames Wrightfrom pathlib import Path 110006be33SJames Wrightimport re 120006be33SJames Wrightimport subprocess 130006be33SJames Wrightimport multiprocessing as mp 140006be33SJames Wrightimport sys 150006be33SJames Wrightimport time 16e45c6f40SZach Atkinsfrom typing import Optional, Tuple, List, Dict, Callable, Iterable, get_origin 17e45c6f40SZach Atkinsimport shutil 180006be33SJames Wright 190006be33SJames Wrightsys.path.insert(0, str(Path(__file__).parent / "junit-xml")) 200006be33SJames Wrightfrom junit_xml import TestCase, TestSuite, to_xml_report_string # nopep8 210006be33SJames Wright 220006be33SJames Wright 23e45c6f40SZach Atkinsclass ParseError(RuntimeError): 24e45c6f40SZach Atkins """A custom exception for failed parsing.""" 25e45c6f40SZach Atkins 26e45c6f40SZach Atkins def __init__(self, message): 27e45c6f40SZach Atkins super().__init__(message) 28e45c6f40SZach Atkins 29e45c6f40SZach Atkins 300006be33SJames Wrightclass CaseInsensitiveEnumAction(argparse.Action): 310006be33SJames Wright """Action to convert input values to lower case prior to converting to an Enum type""" 320006be33SJames Wright 330006be33SJames Wright def __init__(self, option_strings, dest, type, default, **kwargs): 34e45c6f40SZach Atkins if not issubclass(type, Enum): 35e45c6f40SZach Atkins raise ValueError(f"{type} must be an Enum") 360006be33SJames Wright # store provided enum type 370006be33SJames Wright self.enum_type = type 38e45c6f40SZach Atkins if isinstance(default, self.enum_type): 39e45c6f40SZach Atkins pass 40e45c6f40SZach Atkins elif isinstance(default, str): 410006be33SJames Wright default = self.enum_type(default.lower()) 42e45c6f40SZach Atkins elif isinstance(default, Iterable): 430006be33SJames Wright default = [self.enum_type(v.lower()) for v in default] 44e45c6f40SZach Atkins else: 45e45c6f40SZach Atkins raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable") 460006be33SJames Wright # prevent automatic type conversion 470006be33SJames Wright super().__init__(option_strings, dest, default=default, **kwargs) 480006be33SJames Wright 490006be33SJames Wright def __call__(self, parser, namespace, values, option_string=None): 50e45c6f40SZach Atkins if isinstance(values, self.enum_type): 51e45c6f40SZach Atkins pass 52e45c6f40SZach Atkins elif isinstance(values, str): 530006be33SJames Wright values = self.enum_type(values.lower()) 54e45c6f40SZach Atkins elif isinstance(values, Iterable): 550006be33SJames Wright values = [self.enum_type(v.lower()) for v in values] 56e45c6f40SZach Atkins else: 57e45c6f40SZach Atkins raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable") 580006be33SJames Wright setattr(namespace, self.dest, values) 590006be33SJames Wright 600006be33SJames Wright 610006be33SJames Wright@dataclass 620006be33SJames Wrightclass TestSpec: 630006be33SJames Wright """Dataclass storing information about a single test case""" 64e45c6f40SZach Atkins name: str = field(default_factory=str) 65e45c6f40SZach Atkins csv_rtol: float = -1 66e45c6f40SZach Atkins csv_ztol: float = -1 67e45c6f40SZach Atkins cgns_tol: float = -1 680006be33SJames Wright only: List = field(default_factory=list) 690006be33SJames Wright args: List = field(default_factory=list) 70e45c6f40SZach Atkins key_values: Dict = field(default_factory=dict) 710006be33SJames Wright 720006be33SJames Wright 73e45c6f40SZach Atkinsclass RunMode(Enum): 740006be33SJames Wright """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`""" 75e45c6f40SZach Atkins TAP = 'tap' 76e45c6f40SZach Atkins JUNIT = 'junit' 77e45c6f40SZach Atkins 78e45c6f40SZach Atkins def __str__(self): 79e45c6f40SZach Atkins return self.value 80e45c6f40SZach Atkins 81e45c6f40SZach Atkins def __repr__(self): 82e45c6f40SZach Atkins return self.value 830006be33SJames Wright 840006be33SJames Wright 850006be33SJames Wrightclass SuiteSpec(ABC): 860006be33SJames Wright """Abstract Base Class defining the required interface for running a test suite""" 870006be33SJames Wright @abstractmethod 880006be33SJames Wright def get_source_path(self, test: str) -> Path: 890006be33SJames Wright """Compute path to test source file 900006be33SJames Wright 910006be33SJames Wright Args: 920006be33SJames Wright test (str): Name of test 930006be33SJames Wright 940006be33SJames Wright Returns: 950006be33SJames Wright Path: Path to source file 960006be33SJames Wright """ 970006be33SJames Wright raise NotImplementedError 980006be33SJames Wright 990006be33SJames Wright @abstractmethod 1000006be33SJames Wright def get_run_path(self, test: str) -> Path: 1010006be33SJames Wright """Compute path to built test executable file 1020006be33SJames Wright 1030006be33SJames Wright Args: 1040006be33SJames Wright test (str): Name of test 1050006be33SJames Wright 1060006be33SJames Wright Returns: 1070006be33SJames Wright Path: Path to test executable 1080006be33SJames Wright """ 1090006be33SJames Wright raise NotImplementedError 1100006be33SJames Wright 1110006be33SJames Wright @abstractmethod 1120006be33SJames Wright def get_output_path(self, test: str, output_file: str) -> Path: 1130006be33SJames Wright """Compute path to expected output file 1140006be33SJames Wright 1150006be33SJames Wright Args: 1160006be33SJames Wright test (str): Name of test 1170006be33SJames Wright output_file (str): File name of output file 1180006be33SJames Wright 1190006be33SJames Wright Returns: 1200006be33SJames Wright Path: Path to expected output file 1210006be33SJames Wright """ 1220006be33SJames Wright raise NotImplementedError 1230006be33SJames Wright 1240006be33SJames Wright @property 125e45c6f40SZach Atkins def test_failure_artifacts_path(self) -> Path: 126e45c6f40SZach Atkins """Path to test failure artifacts""" 127e45c6f40SZach Atkins return Path('build') / 'test_failure_artifacts' 128e45c6f40SZach Atkins 129e45c6f40SZach Atkins @property 1300006be33SJames Wright def cgns_tol(self): 1310006be33SJames Wright """Absolute tolerance for CGNS diff""" 1320006be33SJames Wright return getattr(self, '_cgns_tol', 1.0e-12) 1330006be33SJames Wright 1340006be33SJames Wright @cgns_tol.setter 1350006be33SJames Wright def cgns_tol(self, val): 1360006be33SJames Wright self._cgns_tol = val 1370006be33SJames Wright 138e941b1e9SJames Wright @property 139e45c6f40SZach Atkins def csv_ztol(self): 140e941b1e9SJames Wright """Keyword arguments to be passed to diff_csv()""" 141e45c6f40SZach Atkins return getattr(self, '_csv_ztol', 3e-10) 142e941b1e9SJames Wright 143e45c6f40SZach Atkins @csv_ztol.setter 144e45c6f40SZach Atkins def csv_ztol(self, val): 145e45c6f40SZach Atkins self._csv_ztol = val 146e941b1e9SJames Wright 147e45c6f40SZach Atkins @property 148e45c6f40SZach Atkins def csv_rtol(self): 149e45c6f40SZach Atkins """Keyword arguments to be passed to diff_csv()""" 150e45c6f40SZach Atkins return getattr(self, '_csv_rtol', 1e-6) 151e45c6f40SZach Atkins 152e45c6f40SZach Atkins @csv_rtol.setter 153e45c6f40SZach Atkins def csv_rtol(self, val): 154e45c6f40SZach Atkins self._csv_rtol = val 155e45c6f40SZach Atkins 156e45c6f40SZach Atkins @property 157e45c6f40SZach Atkins def csv_comment_diff_fn(self): # -> Any | Callable[..., None]: 158e45c6f40SZach Atkins return getattr(self, '_csv_comment_diff_fn', None) 159e45c6f40SZach Atkins 160e45c6f40SZach Atkins @csv_comment_diff_fn.setter 161e45c6f40SZach Atkins def csv_comment_diff_fn(self, test_fn): 162e45c6f40SZach Atkins self._csv_comment_diff_fn = test_fn 163e45c6f40SZach Atkins 164e45c6f40SZach Atkins @property 165e45c6f40SZach Atkins def csv_comment_str(self): 166e45c6f40SZach Atkins return getattr(self, '_csv_comment_str', '#') 167e45c6f40SZach Atkins 168e45c6f40SZach Atkins @csv_comment_str.setter 169e45c6f40SZach Atkins def csv_comment_str(self, comment_str): 170e45c6f40SZach Atkins self._csv_comment_str = comment_str 171e45c6f40SZach Atkins 172e45c6f40SZach Atkins def post_test_hook(self, test: str, spec: TestSpec, backend: str) -> None: 1730006be33SJames Wright """Function callback ran after each test case 1740006be33SJames Wright 1750006be33SJames Wright Args: 1760006be33SJames Wright test (str): Name of test 1770006be33SJames Wright spec (TestSpec): Test case specification 1780006be33SJames Wright """ 1790006be33SJames Wright pass 1800006be33SJames Wright 1810006be33SJames Wright def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]: 1820006be33SJames Wright """Check if a test case should be skipped prior to running, returning the reason for skipping 1830006be33SJames Wright 1840006be33SJames Wright Args: 1850006be33SJames Wright test (str): Name of test 1860006be33SJames Wright spec (TestSpec): Test case specification 1870006be33SJames Wright resource (str): libCEED backend 1880006be33SJames Wright nproc (int): Number of MPI processes to use when running test case 1890006be33SJames Wright 1900006be33SJames Wright Returns: 1910006be33SJames Wright Optional[str]: Skip reason, or `None` if test case should not be skipped 1920006be33SJames Wright """ 1930006be33SJames Wright return None 1940006be33SJames Wright 1950006be33SJames Wright def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]: 1960006be33SJames Wright """Check if a test case should be allowed to fail, based on its stderr output 1970006be33SJames Wright 1980006be33SJames Wright Args: 1990006be33SJames Wright test (str): Name of test 2000006be33SJames Wright spec (TestSpec): Test case specification 2010006be33SJames Wright resource (str): libCEED backend 2020006be33SJames Wright stderr (str): Standard error output from test case execution 2030006be33SJames Wright 2040006be33SJames Wright Returns: 2050006be33SJames Wright Optional[str]: Skip reason, or `None` if unexpected error 2060006be33SJames Wright """ 2070006be33SJames Wright return None 2080006be33SJames Wright 2090006be33SJames Wright def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]: 2100006be33SJames Wright """Check whether a test case is expected to fail and if it failed expectedly 2110006be33SJames Wright 2120006be33SJames Wright Args: 2130006be33SJames Wright test (str): Name of test 2140006be33SJames Wright spec (TestSpec): Test case specification 2150006be33SJames Wright resource (str): libCEED backend 2160006be33SJames Wright stderr (str): Standard error output from test case execution 2170006be33SJames Wright 2180006be33SJames Wright Returns: 2190006be33SJames Wright tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr` 2200006be33SJames Wright """ 2210006be33SJames Wright return '', True 2220006be33SJames Wright 2230006be33SJames Wright def check_allowed_stdout(self, test: str) -> bool: 2240006be33SJames Wright """Check whether a test is allowed to print console output 2250006be33SJames Wright 2260006be33SJames Wright Args: 2270006be33SJames Wright test (str): Name of test 2280006be33SJames Wright 2290006be33SJames Wright Returns: 2300006be33SJames Wright bool: True if the test is allowed to print console output 2310006be33SJames Wright """ 2320006be33SJames Wright return False 2330006be33SJames Wright 2340006be33SJames Wright 2350006be33SJames Wrightdef has_cgnsdiff() -> bool: 2360006be33SJames Wright """Check whether `cgnsdiff` is an executable program in the current environment 2370006be33SJames Wright 2380006be33SJames Wright Returns: 2390006be33SJames Wright bool: True if `cgnsdiff` is found 2400006be33SJames Wright """ 2410006be33SJames Wright my_env: dict = os.environ.copy() 2420006be33SJames Wright proc = subprocess.run('cgnsdiff', 2430006be33SJames Wright shell=True, 2440006be33SJames Wright stdout=subprocess.PIPE, 2450006be33SJames Wright stderr=subprocess.PIPE, 2460006be33SJames Wright env=my_env) 2470006be33SJames Wright return 'not found' not in proc.stderr.decode('utf-8') 2480006be33SJames Wright 2490006be33SJames Wright 2500006be33SJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool: 2510006be33SJames Wright """Helper function, checks if any of the substrings are included in the base string 2520006be33SJames Wright 2530006be33SJames Wright Args: 2540006be33SJames Wright base (str): Base string to search in 2550006be33SJames Wright substrings (List[str]): List of potential substrings 2560006be33SJames Wright 2570006be33SJames Wright Returns: 2580006be33SJames Wright bool: True if any substrings are included in base string 2590006be33SJames Wright """ 2600006be33SJames Wright return any((sub in base for sub in substrings)) 2610006be33SJames Wright 2620006be33SJames Wright 2630006be33SJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool: 2640006be33SJames Wright """Helper function, checks if the base string is prefixed by any of `prefixes` 2650006be33SJames Wright 2660006be33SJames Wright Args: 2670006be33SJames Wright base (str): Base string to search 2680006be33SJames Wright prefixes (List[str]): List of potential prefixes 2690006be33SJames Wright 2700006be33SJames Wright Returns: 2710006be33SJames Wright bool: True if base string is prefixed by any of the prefixes 2720006be33SJames Wright """ 2730006be33SJames Wright return any((base.startswith(prefix) for prefix in prefixes)) 2740006be33SJames Wright 2750006be33SJames Wright 276e45c6f40SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]: 277e45c6f40SZach Atkins """Find the start and end positions of the first outer paired delimeters 278e45c6f40SZach Atkins 279e45c6f40SZach Atkins Args: 280e45c6f40SZach Atkins line (str): Line to search 281e45c6f40SZach Atkins open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('. 282e45c6f40SZach Atkins close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'. 283e45c6f40SZach Atkins 284e45c6f40SZach Atkins Raises: 285e45c6f40SZach Atkins RuntimeError: If open or close is not a single character 286e45c6f40SZach Atkins RuntimeError: If open and close are the same characters 287e45c6f40SZach Atkins 288e45c6f40SZach Atkins Returns: 289e45c6f40SZach Atkins Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start. 290e45c6f40SZach Atkins """ 291e45c6f40SZach Atkins if len(open) != 1 or len(close) != 1: 292e45c6f40SZach Atkins raise RuntimeError("`open` and `close` must be single characters") 293e45c6f40SZach Atkins if open == close: 294e45c6f40SZach Atkins raise RuntimeError("`open` and `close` must be different characters") 295e45c6f40SZach Atkins start: int = line.find(open) 296e45c6f40SZach Atkins if start < 0: 297e45c6f40SZach Atkins return -1, -1 298e45c6f40SZach Atkins count: int = 1 299e45c6f40SZach Atkins for i in range(start + 1, len(line)): 300e45c6f40SZach Atkins if line[i] == open: 301e45c6f40SZach Atkins count += 1 302e45c6f40SZach Atkins if line[i] == close: 303e45c6f40SZach Atkins count -= 1 304e45c6f40SZach Atkins if count == 0: 305e45c6f40SZach Atkins return start, i 306e45c6f40SZach Atkins return start, -1 307e45c6f40SZach Atkins 308e45c6f40SZach Atkins 309e45c6f40SZach Atkinsdef parse_test_line(line: str, fallback_name: str = '') -> TestSpec: 3100006be33SJames Wright """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object 3110006be33SJames Wright 3120006be33SJames Wright Args: 3130006be33SJames Wright line (str): String containing TESTARGS specification and CLI arguments 3140006be33SJames Wright 3150006be33SJames Wright Returns: 3160006be33SJames Wright TestSpec: Parsed specification of test case 3170006be33SJames Wright """ 318e45c6f40SZach Atkins test_fields = fields(TestSpec) 319e45c6f40SZach Atkins field_names = [f.name for f in test_fields] 320e45c6f40SZach Atkins known: Dict = dict() 321e45c6f40SZach Atkins other: Dict = dict() 322e45c6f40SZach Atkins if line[0] == "(": 323e45c6f40SZach Atkins # have key/value pairs to parse 324e45c6f40SZach Atkins start, end = find_matching(line) 325e45c6f40SZach Atkins if end < start: 326e45c6f40SZach Atkins raise ParseError(f"Mismatched parentheses in TESTCASE: {line}") 327e45c6f40SZach Atkins 328e45c6f40SZach Atkins keyvalues_str = line[start:end + 1] 329e45c6f40SZach Atkins keyvalues_pattern = re.compile(r''' 330e45c6f40SZach Atkins (?:\(\s*|\s*,\s*) # start with open parentheses or comma, no capture 331e45c6f40SZach Atkins ([A-Za-z]+[\w\-]+) # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1 332e45c6f40SZach Atkins \s*=\s* # key is followed by = (whitespace ignored) 333e45c6f40SZach Atkins (?: # uncaptured group for OR 334e45c6f40SZach Atkins "((?:[^"]|\\")+)" # match quoted value (any internal " must be escaped as \"); captured as Group 2 335e45c6f40SZach Atkins | ([^=]+) # OR match unquoted value (no equals signs allowed); captured as Group 3 336e45c6f40SZach Atkins ) # end uncaptured group for OR 337e45c6f40SZach Atkins \s*(?=,|\)) # lookahead for either next comma or closing parentheses 338e45c6f40SZach Atkins ''', re.VERBOSE) 339e45c6f40SZach Atkins 340e45c6f40SZach Atkins for match in re.finditer(keyvalues_pattern, keyvalues_str): 341e45c6f40SZach Atkins if not match: # empty 342e45c6f40SZach Atkins continue 343e45c6f40SZach Atkins key = match.group(1) 344e45c6f40SZach Atkins value = match.group(2) if match.group(2) else match.group(3) 345e45c6f40SZach Atkins try: 346e45c6f40SZach Atkins index = field_names.index(key) 347e45c6f40SZach Atkins if key == "only": # weird bc only is a list 348e45c6f40SZach Atkins value = [constraint.strip() for constraint in value.split(',')] 349e45c6f40SZach Atkins try: 350e45c6f40SZach Atkins # TODO: stop supporting python <=3.8 351e45c6f40SZach Atkins known[key] = test_fields[index].type(value) # type: ignore 352e45c6f40SZach Atkins except TypeError: 353e45c6f40SZach Atkins # TODO: this is still liable to fail for complex types 354e45c6f40SZach Atkins known[key] = get_origin(test_fields[index].type)(value) # type: ignore 355e45c6f40SZach Atkins except ValueError: 356e45c6f40SZach Atkins other[key] = value 357e45c6f40SZach Atkins 358e45c6f40SZach Atkins line = line[end + 1:] 359e45c6f40SZach Atkins 360e45c6f40SZach Atkins if not 'name' in known.keys(): 361e45c6f40SZach Atkins known['name'] = fallback_name 362e45c6f40SZach Atkins 363e45c6f40SZach Atkins args_pattern = re.compile(r''' 364e45c6f40SZach Atkins \s+( # remove leading space 365e45c6f40SZach Atkins (?:"[^"]+") # match quoted CLI option 366e45c6f40SZach Atkins | (?:[\S]+) # match anything else that is space separated 367e45c6f40SZach Atkins ) 368e45c6f40SZach Atkins ''', re.VERBOSE) 369e45c6f40SZach Atkins args: List[str] = re.findall(args_pattern, line) 370e45c6f40SZach Atkins for k, v in other.items(): 371e45c6f40SZach Atkins print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}") 372e45c6f40SZach Atkins return TestSpec(**known, key_values=other, args=args) 3730006be33SJames Wright 3740006be33SJames Wright 3750006be33SJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]: 3760006be33SJames Wright """Parse all test cases from a given source file 3770006be33SJames Wright 3780006be33SJames Wright Args: 3790006be33SJames Wright source_file (Path): Path to source file 3800006be33SJames Wright 3810006be33SJames Wright Raises: 3820006be33SJames Wright RuntimeError: Errors if source file extension is unsupported 3830006be33SJames Wright 3840006be33SJames Wright Returns: 3850006be33SJames Wright List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found 3860006be33SJames Wright """ 3870006be33SJames Wright comment_str: str = '' 3880006be33SJames Wright if source_file.suffix in ['.c', '.cc', '.cpp']: 3890006be33SJames Wright comment_str = '//' 3900006be33SJames Wright elif source_file.suffix in ['.py']: 3910006be33SJames Wright comment_str = '#' 3920006be33SJames Wright elif source_file.suffix in ['.usr']: 3930006be33SJames Wright comment_str = 'C_' 3940006be33SJames Wright elif source_file.suffix in ['.f90']: 3950006be33SJames Wright comment_str = '! ' 3960006be33SJames Wright else: 3970006be33SJames Wright raise RuntimeError(f'Unrecognized extension for file: {source_file}') 3980006be33SJames Wright 399e45c6f40SZach Atkins return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS"), source_file.stem) 4000006be33SJames Wright for line in source_file.read_text().splitlines() 401e45c6f40SZach Atkins if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec(source_file.stem, args=['{ceed_resource}'])] 4020006be33SJames Wright 4030006be33SJames Wright 404e45c6f40SZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float, rel_tol: float, 405e941b1e9SJames Wright comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str: 4060006be33SJames Wright """Compare CSV results against an expected CSV file with tolerances 4070006be33SJames Wright 4080006be33SJames Wright Args: 4090006be33SJames Wright test_csv (Path): Path to output CSV results 4100006be33SJames Wright true_csv (Path): Path to expected CSV results 411e45c6f40SZach Atkins zero_tol (float): Tolerance below which values are considered to be zero. 412e45c6f40SZach Atkins rel_tol (float): Relative tolerance for comparing non-zero values. 413e941b1e9SJames Wright comment_str (str, optional): String to denoting commented line 414e941b1e9SJames Wright comment_func (Callable, optional): Function to determine if test and true line are different 4150006be33SJames Wright 4160006be33SJames Wright Returns: 4170006be33SJames Wright str: Diff output between result and expected CSVs 4180006be33SJames Wright """ 4190006be33SJames Wright test_lines: List[str] = test_csv.read_text().splitlines() 4200006be33SJames Wright true_lines: List[str] = true_csv.read_text().splitlines() 4210006be33SJames Wright # Files should not be empty 4220006be33SJames Wright if len(test_lines) == 0: 4230006be33SJames Wright return f'No lines found in test output {test_csv}' 4240006be33SJames Wright if len(true_lines) == 0: 4250006be33SJames Wright return f'No lines found in test source {true_csv}' 426e941b1e9SJames Wright if len(test_lines) != len(true_lines): 427e941b1e9SJames Wright return f'Number of lines in {test_csv} and {true_csv} do not match' 428e941b1e9SJames Wright 429e941b1e9SJames Wright # Process commented lines 430e941b1e9SJames Wright uncommented_lines: List[int] = [] 431e941b1e9SJames Wright for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)): 432e941b1e9SJames Wright if test_line[0] == comment_str and true_line[0] == comment_str: 433e941b1e9SJames Wright if comment_func: 434e941b1e9SJames Wright output = comment_func(test_line, true_line) 435e941b1e9SJames Wright if output: 436e941b1e9SJames Wright return output 437e941b1e9SJames Wright elif test_line[0] == comment_str and true_line[0] != comment_str: 438e941b1e9SJames Wright return f'Commented line found in {test_csv} at line {n} but not in {true_csv}' 439e941b1e9SJames Wright elif test_line[0] != comment_str and true_line[0] == comment_str: 440e941b1e9SJames Wright return f'Commented line found in {true_csv} at line {n} but not in {test_csv}' 441e941b1e9SJames Wright else: 442e941b1e9SJames Wright uncommented_lines.append(n) 443e941b1e9SJames Wright 444e941b1e9SJames Wright # Remove commented lines 445e941b1e9SJames Wright test_lines = [test_lines[line] for line in uncommented_lines] 446e941b1e9SJames Wright true_lines = [true_lines[line] for line in uncommented_lines] 4470006be33SJames Wright 4480006be33SJames Wright test_reader: csv.DictReader = csv.DictReader(test_lines) 4490006be33SJames Wright true_reader: csv.DictReader = csv.DictReader(true_lines) 450e45c6f40SZach Atkins if not test_reader.fieldnames: 451e45c6f40SZach Atkins return f'No CSV columns found in test output {test_csv}' 452e45c6f40SZach Atkins if not true_reader.fieldnames: 453e45c6f40SZach Atkins return f'No CSV columns found in test source {true_csv}' 4540006be33SJames Wright if test_reader.fieldnames != true_reader.fieldnames: 4550006be33SJames Wright return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'], 4560006be33SJames Wright tofile='found CSV columns', fromfile='expected CSV columns')) 4570006be33SJames Wright 4580006be33SJames Wright diff_lines: List[str] = list() 4590006be33SJames Wright for test_line, true_line in zip(test_reader, true_reader): 4600006be33SJames Wright for key in test_reader.fieldnames: 4610006be33SJames Wright # Check if the value is numeric 4620006be33SJames Wright try: 4630006be33SJames Wright true_val: float = float(true_line[key]) 4640006be33SJames Wright test_val: float = float(test_line[key]) 4650006be33SJames Wright true_zero: bool = abs(true_val) < zero_tol 4660006be33SJames Wright test_zero: bool = abs(test_val) < zero_tol 4670006be33SJames Wright fail: bool = False 4680006be33SJames Wright if true_zero: 4690006be33SJames Wright fail = not test_zero 4700006be33SJames Wright else: 4710006be33SJames Wright fail = not isclose(test_val, true_val, rel_tol=rel_tol) 4720006be33SJames Wright if fail: 4730006be33SJames Wright diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}') 4740006be33SJames Wright except ValueError: 4750006be33SJames Wright if test_line[key] != true_line[key]: 4760006be33SJames Wright diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}') 4770006be33SJames Wright 4780006be33SJames Wright return '\n'.join(diff_lines) 4790006be33SJames Wright 4800006be33SJames Wright 481e45c6f40SZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float) -> str: 4820006be33SJames Wright """Compare CGNS results against an expected CGSN file with tolerance 4830006be33SJames Wright 4840006be33SJames Wright Args: 4850006be33SJames Wright test_cgns (Path): Path to output CGNS file 4860006be33SJames Wright true_cgns (Path): Path to expected CGNS file 487e45c6f40SZach Atkins cgns_tol (float): Tolerance for comparing floating-point values 4880006be33SJames Wright 4890006be33SJames Wright Returns: 4900006be33SJames Wright str: Diff output between result and expected CGNS files 4910006be33SJames Wright """ 4920006be33SJames Wright my_env: dict = os.environ.copy() 4930006be33SJames Wright 4940006be33SJames Wright run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)] 4950006be33SJames Wright proc = subprocess.run(' '.join(run_args), 4960006be33SJames Wright shell=True, 4970006be33SJames Wright stdout=subprocess.PIPE, 4980006be33SJames Wright stderr=subprocess.PIPE, 4990006be33SJames Wright env=my_env) 5000006be33SJames Wright 5010006be33SJames Wright return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8') 5020006be33SJames Wright 5030006be33SJames Wright 504e45c6f40SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str: 505e45c6f40SZach Atkins """Compare ASCII results against an expected ASCII file 506e45c6f40SZach Atkins 507e45c6f40SZach Atkins Args: 508e45c6f40SZach Atkins test_file (Path): Path to output ASCII file 509e45c6f40SZach Atkins true_file (Path): Path to expected ASCII file 510e45c6f40SZach Atkins 511e45c6f40SZach Atkins Returns: 512e45c6f40SZach Atkins str: Diff output between result and expected ASCII files 513e45c6f40SZach Atkins """ 514e45c6f40SZach Atkins tmp_backend: str = backend.replace('/', '-') 515e45c6f40SZach Atkins true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend) 516e45c6f40SZach Atkins diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True), 517e45c6f40SZach Atkins true_str.splitlines(keepends=True), 518e45c6f40SZach Atkins fromfile=str(test_file), 519e45c6f40SZach Atkins tofile=str(true_file))) 520e45c6f40SZach Atkins return ''.join(diff) 521e45c6f40SZach Atkins 522e45c6f40SZach Atkins 5230006be33SJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode, 524e45c6f40SZach Atkins backend: str, test: str, index: int, verbose: bool) -> str: 5250006be33SJames Wright output_str = '' 5260006be33SJames Wright if mode is RunMode.TAP: 5270006be33SJames Wright # print incremental output if TAP mode 5280006be33SJames Wright if test_case.is_skipped(): 5290006be33SJames Wright output_str += f' ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n' 5300006be33SJames Wright elif test_case.is_failure() or test_case.is_error(): 531e45c6f40SZach Atkins output_str += f' not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 5320006be33SJames Wright else: 533e45c6f40SZach Atkins output_str += f' ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n' 534e45c6f40SZach Atkins if test_case.is_failure() or test_case.is_error() or verbose: 5350006be33SJames Wright output_str += f' ---\n' 5360006be33SJames Wright if spec.only: 5370006be33SJames Wright output_str += f' only: {",".join(spec.only)}\n' 5380006be33SJames Wright output_str += f' args: {test_case.args}\n' 539e45c6f40SZach Atkins if spec.csv_ztol > 0: 540e45c6f40SZach Atkins output_str += f' csv_ztol: {spec.csv_ztol}\n' 541e45c6f40SZach Atkins if spec.csv_rtol > 0: 542e45c6f40SZach Atkins output_str += f' csv_rtol: {spec.csv_rtol}\n' 543e45c6f40SZach Atkins if spec.cgns_tol > 0: 544e45c6f40SZach Atkins output_str += f' cgns_tol: {spec.cgns_tol}\n' 545e45c6f40SZach Atkins for k, v in spec.key_values.items(): 546e45c6f40SZach Atkins output_str += f' {k}: {v}\n' 5470006be33SJames Wright if test_case.is_error(): 5480006be33SJames Wright output_str += f' error: {test_case.errors[0]["message"]}\n' 5490006be33SJames Wright if test_case.is_failure(): 550e45c6f40SZach Atkins output_str += f' failures:\n' 5510006be33SJames Wright for i, failure in enumerate(test_case.failures): 552e45c6f40SZach Atkins output_str += f' -\n' 5530006be33SJames Wright output_str += f' message: {failure["message"]}\n' 5540006be33SJames Wright if failure["output"]: 5550006be33SJames Wright out = failure["output"].strip().replace('\n', '\n ') 5560006be33SJames Wright output_str += f' output: |\n {out}\n' 5570006be33SJames Wright output_str += f' ...\n' 5580006be33SJames Wright else: 5590006be33SJames Wright # print error or failure information if JUNIT mode 5600006be33SJames Wright if test_case.is_error() or test_case.is_failure(): 5610006be33SJames Wright output_str += f'Test: {test} {spec.name}\n' 5620006be33SJames Wright output_str += f' $ {test_case.args}\n' 5630006be33SJames Wright if test_case.is_error(): 5640006be33SJames Wright output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()) 5650006be33SJames Wright output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()) 5660006be33SJames Wright if test_case.is_failure(): 5670006be33SJames Wright for failure in test_case.failures: 5680006be33SJames Wright output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip()) 5690006be33SJames Wright output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip()) 5700006be33SJames Wright return output_str 5710006be33SJames Wright 5720006be33SJames Wright 573e45c6f40SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path: 574e45c6f40SZach Atkins """Attach a file to a test case 575e45c6f40SZach Atkins 576e45c6f40SZach Atkins Args: 577e45c6f40SZach Atkins test_case (TestCase): Test case to attach the file to 578e45c6f40SZach Atkins file (Path): Path to the file to attach 579e45c6f40SZach Atkins """ 580e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / file.name 581e45c6f40SZach Atkins shutil.copyfile(file, save_path) 582e45c6f40SZach Atkins return save_path 583e45c6f40SZach Atkins 584e45c6f40SZach Atkins 5850006be33SJames Wrightdef run_test(index: int, test: str, spec: TestSpec, backend: str, 586e45c6f40SZach Atkins mode: RunMode, nproc: int, suite_spec: SuiteSpec, verbose: bool = False) -> TestCase: 5870006be33SJames Wright """Run a single test case and backend combination 5880006be33SJames Wright 5890006be33SJames Wright Args: 5900006be33SJames Wright index (int): Index of backend for current spec 5910006be33SJames Wright test (str): Path to test 5920006be33SJames Wright spec (TestSpec): Specification of test case 5930006be33SJames Wright backend (str): CEED backend 5940006be33SJames Wright mode (RunMode): Output mode 5950006be33SJames Wright nproc (int): Number of MPI processes to use when running test case 5960006be33SJames Wright suite_spec (SuiteSpec): Specification of test suite 597e45c6f40SZach Atkins verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False. 5980006be33SJames Wright 5990006be33SJames Wright Returns: 6000006be33SJames Wright TestCase: Test case result 6010006be33SJames Wright """ 6020006be33SJames Wright source_path: Path = suite_spec.get_source_path(test) 6030006be33SJames Wright run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)] 6040006be33SJames Wright 6050006be33SJames Wright if '{ceed_resource}' in run_args: 6060006be33SJames Wright run_args[run_args.index('{ceed_resource}')] = backend 6070006be33SJames Wright for i, arg in enumerate(run_args): 6080006be33SJames Wright if '{ceed_resource}' in arg: 6090006be33SJames Wright run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-')) 6100006be33SJames Wright if '{nproc}' in run_args: 6110006be33SJames Wright run_args[run_args.index('{nproc}')] = f'{nproc}' 6120006be33SJames Wright elif nproc > 1 and source_path.suffix != '.py': 6130006be33SJames Wright run_args = ['mpiexec', '-n', f'{nproc}', *run_args] 6140006be33SJames Wright 6150006be33SJames Wright # run test 616e45c6f40SZach Atkins skip_reason: Optional[str] = suite_spec.check_pre_skip(test, spec, backend, nproc) 6170006be33SJames Wright if skip_reason: 6180006be33SJames Wright test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 6190006be33SJames Wright elapsed_sec=0, 6200006be33SJames Wright timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()), 6210006be33SJames Wright stdout='', 6220006be33SJames Wright stderr='', 6230006be33SJames Wright category=spec.name,) 6240006be33SJames Wright test_case.add_skipped_info(skip_reason) 6250006be33SJames Wright else: 6260006be33SJames Wright start: float = time.time() 6270006be33SJames Wright proc = subprocess.run(' '.join(str(arg) for arg in run_args), 6280006be33SJames Wright shell=True, 6290006be33SJames Wright stdout=subprocess.PIPE, 6300006be33SJames Wright stderr=subprocess.PIPE, 6310006be33SJames Wright env=my_env) 6320006be33SJames Wright 6330006be33SJames Wright test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 6340006be33SJames Wright classname=source_path.parent, 6350006be33SJames Wright elapsed_sec=time.time() - start, 6360006be33SJames Wright timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)), 6370006be33SJames Wright stdout=proc.stdout.decode('utf-8'), 6380006be33SJames Wright stderr=proc.stderr.decode('utf-8'), 6390006be33SJames Wright allow_multiple_subelements=True, 6400006be33SJames Wright category=spec.name,) 6410006be33SJames Wright ref_csvs: List[Path] = [] 642e45c6f40SZach Atkins ref_ascii: List[Path] = [] 643e45c6f40SZach Atkins output_files: List[str] = [arg.split(':')[1] for arg in run_args if arg.startswith('ascii:')] 6440006be33SJames Wright if output_files: 645e45c6f40SZach Atkins ref_csvs = [suite_spec.get_output_path(test, file) 646e45c6f40SZach Atkins for file in output_files if file.endswith('.csv')] 647e45c6f40SZach Atkins ref_ascii = [suite_spec.get_output_path(test, file) 648e45c6f40SZach Atkins for file in output_files if not file.endswith('.csv')] 6490006be33SJames Wright ref_cgns: List[Path] = [] 650e45c6f40SZach Atkins output_files = [arg.split(':')[1] for arg in run_args if arg.startswith('cgns:')] 6510006be33SJames Wright if output_files: 652e45c6f40SZach Atkins ref_cgns = [suite_spec.get_output_path(test, file) for file in output_files] 6530006be33SJames Wright ref_stdout: Path = suite_spec.get_output_path(test, test + '.out') 654e45c6f40SZach Atkins suite_spec.post_test_hook(test, spec, backend) 6550006be33SJames Wright 6560006be33SJames Wright # check allowed failures 6570006be33SJames Wright if not test_case.is_skipped() and test_case.stderr: 658e45c6f40SZach Atkins skip_reason: Optional[str] = suite_spec.check_post_skip(test, spec, backend, test_case.stderr) 6590006be33SJames Wright if skip_reason: 6600006be33SJames Wright test_case.add_skipped_info(skip_reason) 6610006be33SJames Wright 6620006be33SJames Wright # check required failures 6630006be33SJames Wright if not test_case.is_skipped(): 6640006be33SJames Wright required_message, did_fail = suite_spec.check_required_failure( 6650006be33SJames Wright test, spec, backend, test_case.stderr) 6660006be33SJames Wright if required_message and did_fail: 6670006be33SJames Wright test_case.status = f'fails with required: {required_message}' 6680006be33SJames Wright elif required_message: 6690006be33SJames Wright test_case.add_failure_info(f'required failure missing: {required_message}') 6700006be33SJames Wright 6710006be33SJames Wright # classify other results 6720006be33SJames Wright if not test_case.is_skipped() and not test_case.status: 6730006be33SJames Wright if test_case.stderr: 6740006be33SJames Wright test_case.add_failure_info('stderr', test_case.stderr) 6750006be33SJames Wright if proc.returncode != 0: 6760006be33SJames Wright test_case.add_error_info(f'returncode = {proc.returncode}') 6770006be33SJames Wright if ref_stdout.is_file(): 6780006be33SJames Wright diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True), 6790006be33SJames Wright test_case.stdout.splitlines(keepends=True), 6800006be33SJames Wright fromfile=str(ref_stdout), 6810006be33SJames Wright tofile='New')) 6820006be33SJames Wright if diff: 6830006be33SJames Wright test_case.add_failure_info('stdout', output=''.join(diff)) 6840006be33SJames Wright elif test_case.stdout and not suite_spec.check_allowed_stdout(test): 6850006be33SJames Wright test_case.add_failure_info('stdout', output=test_case.stdout) 6860006be33SJames Wright # expected CSV output 6870006be33SJames Wright for ref_csv in ref_csvs: 6880006be33SJames Wright csv_name = ref_csv.name 689e45c6f40SZach Atkins out_file = Path.cwd() / csv_name 6900006be33SJames Wright if not ref_csv.is_file(): 6910006be33SJames Wright # remove _{ceed_backend} from path name 6920006be33SJames Wright ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv') 6930006be33SJames Wright if not ref_csv.is_file(): 6940006be33SJames Wright test_case.add_failure_info('csv', output=f'{ref_csv} not found') 695e45c6f40SZach Atkins elif not out_file.is_file(): 696e45c6f40SZach Atkins test_case.add_failure_info('csv', output=f'{out_file} not found') 6970006be33SJames Wright else: 698e45c6f40SZach Atkins csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol 699e45c6f40SZach Atkins csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol 700e45c6f40SZach Atkins diff = diff_csv( 701e45c6f40SZach Atkins out_file, 702e45c6f40SZach Atkins ref_csv, 703e45c6f40SZach Atkins csv_ztol, 704e45c6f40SZach Atkins csv_rtol, 705e45c6f40SZach Atkins suite_spec.csv_comment_str, 706e45c6f40SZach Atkins suite_spec.csv_comment_diff_fn) 7070006be33SJames Wright if diff: 708e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / csv_name 709e45c6f40SZach Atkins shutil.move(out_file, save_path) 710e45c6f40SZach Atkins test_case.add_failure_info(f'csv: {save_path}', output=diff) 7110006be33SJames Wright else: 712e45c6f40SZach Atkins out_file.unlink() 7130006be33SJames Wright # expected CGNS output 7140006be33SJames Wright for ref_cgn in ref_cgns: 7150006be33SJames Wright cgn_name = ref_cgn.name 716e45c6f40SZach Atkins out_file = Path.cwd() / cgn_name 7170006be33SJames Wright if not ref_cgn.is_file(): 7180006be33SJames Wright # remove _{ceed_backend} from path name 7190006be33SJames Wright ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns') 7200006be33SJames Wright if not ref_cgn.is_file(): 7210006be33SJames Wright test_case.add_failure_info('cgns', output=f'{ref_cgn} not found') 722e45c6f40SZach Atkins elif not out_file.is_file(): 723e45c6f40SZach Atkins test_case.add_failure_info('cgns', output=f'{out_file} not found') 7240006be33SJames Wright else: 725e45c6f40SZach Atkins cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol 726e45c6f40SZach Atkins diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol) 7270006be33SJames Wright if diff: 728e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name 729e45c6f40SZach Atkins shutil.move(out_file, save_path) 730e45c6f40SZach Atkins test_case.add_failure_info(f'cgns: {save_path}', output=diff) 7310006be33SJames Wright else: 732e45c6f40SZach Atkins out_file.unlink() 733e45c6f40SZach Atkins # expected ASCII output 734e45c6f40SZach Atkins for ref_file in ref_ascii: 735e45c6f40SZach Atkins ref_name = ref_file.name 736e45c6f40SZach Atkins out_file = Path.cwd() / ref_name 737e45c6f40SZach Atkins if not ref_file.is_file(): 738e45c6f40SZach Atkins # remove _{ceed_backend} from path name 739e45c6f40SZach Atkins ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix) 740e45c6f40SZach Atkins if not ref_file.is_file(): 741e45c6f40SZach Atkins test_case.add_failure_info('ascii', output=f'{ref_file} not found') 742e45c6f40SZach Atkins elif not out_file.is_file(): 743e45c6f40SZach Atkins test_case.add_failure_info('ascii', output=f'{out_file} not found') 744e45c6f40SZach Atkins else: 745e45c6f40SZach Atkins diff = diff_ascii(out_file, ref_file, backend) 746e45c6f40SZach Atkins if diff: 747e45c6f40SZach Atkins save_path: Path = suite_spec.test_failure_artifacts_path / ref_name 748e45c6f40SZach Atkins shutil.move(out_file, save_path) 749e45c6f40SZach Atkins test_case.add_failure_info(f'ascii: {save_path}', output=diff) 750e45c6f40SZach Atkins else: 751e45c6f40SZach Atkins out_file.unlink() 7520006be33SJames Wright 7530006be33SJames Wright # store result 7540006be33SJames Wright test_case.args = ' '.join(str(arg) for arg in run_args) 755e45c6f40SZach Atkins output_str = test_case_output_string(test_case, spec, mode, backend, test, index, verbose) 7560006be33SJames Wright 7570006be33SJames Wright return test_case, output_str 7580006be33SJames Wright 7590006be33SJames Wright 7600006be33SJames Wrightdef init_process(): 7610006be33SJames Wright """Initialize multiprocessing process""" 7620006be33SJames Wright # set up error handler 7630006be33SJames Wright global my_env 7640006be33SJames Wright my_env = os.environ.copy() 7650006be33SJames Wright my_env['CEED_ERROR_HANDLER'] = 'exit' 7660006be33SJames Wright 7670006be33SJames Wright 7680006be33SJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int, 769e45c6f40SZach Atkins suite_spec: SuiteSpec, pool_size: int = 1, search: str = ".*", verbose: bool = False) -> TestSuite: 7700006be33SJames Wright """Run all test cases for `test` with each of the provided `ceed_backends` 7710006be33SJames Wright 7720006be33SJames Wright Args: 7730006be33SJames Wright test (str): Name of test 7740006be33SJames Wright ceed_backends (List[str]): List of libCEED backends 7750006be33SJames Wright mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT` 7760006be33SJames Wright nproc (int): Number of MPI processes to use when running each test case 7770006be33SJames Wright suite_spec (SuiteSpec): Object defining required methods for running tests 7780006be33SJames Wright pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1. 779e45c6f40SZach Atkins search (str, optional): Regular expression used to match tests. Defaults to ".*". 780e45c6f40SZach Atkins verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False. 7810006be33SJames Wright 7820006be33SJames Wright Returns: 7830006be33SJames Wright TestSuite: JUnit `TestSuite` containing results of all test cases 7840006be33SJames Wright """ 785e45c6f40SZach Atkins test_specs: List[TestSpec] = [ 786e45c6f40SZach Atkins t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE) 787e45c6f40SZach Atkins ] 788e45c6f40SZach Atkins suite_spec.test_failure_artifacts_path.mkdir(parents=True, exist_ok=True) 7890006be33SJames Wright if mode is RunMode.TAP: 7900006be33SJames Wright print('TAP version 13') 7910006be33SJames Wright print(f'1..{len(test_specs)}') 7920006be33SJames Wright 7930006be33SJames Wright with mp.Pool(processes=pool_size, initializer=init_process) as pool: 794e45c6f40SZach Atkins async_outputs: List[List[mp.pool.AsyncResult]] = [ 795e45c6f40SZach Atkins [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec, verbose)) 7960006be33SJames Wright for (i, backend) in enumerate(ceed_backends, start=1)] 7970006be33SJames Wright for spec in test_specs 7980006be33SJames Wright ] 7990006be33SJames Wright 8000006be33SJames Wright test_cases = [] 8010006be33SJames Wright for (i, subtest) in enumerate(async_outputs, start=1): 8020006be33SJames Wright is_new_subtest = True 8030006be33SJames Wright subtest_ok = True 8040006be33SJames Wright for async_output in subtest: 8050006be33SJames Wright test_case, print_output = async_output.get() 8060006be33SJames Wright test_cases.append(test_case) 8070006be33SJames Wright if is_new_subtest and mode == RunMode.TAP: 8080006be33SJames Wright is_new_subtest = False 8090006be33SJames Wright print(f'# Subtest: {test_case.category}') 8100006be33SJames Wright print(f' 1..{len(ceed_backends)}') 8110006be33SJames Wright print(print_output, end='') 8120006be33SJames Wright if test_case.is_failure() or test_case.is_error(): 8130006be33SJames Wright subtest_ok = False 8140006be33SJames Wright if mode == RunMode.TAP: 8150006be33SJames Wright print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}') 8160006be33SJames Wright 8170006be33SJames Wright return TestSuite(test, test_cases) 8180006be33SJames Wright 8190006be33SJames Wright 820*f5d9ab20SJames Wrightdef write_junit_xml(test_suite: TestSuite, batch: str = '') -> None: 8210006be33SJames Wright """Write a JUnit XML file containing the results of a `TestSuite` 8220006be33SJames Wright 8230006be33SJames Wright Args: 8240006be33SJames Wright test_suite (TestSuite): JUnit `TestSuite` to write 8250006be33SJames Wright batch (str): Name of JUnit batch, defaults to empty string 8260006be33SJames Wright """ 827*f5d9ab20SJames Wright output_file = Path('build') / (f'{test_suite.name}{batch}.junit') 8280006be33SJames Wright output_file.write_text(to_xml_report_string([test_suite])) 8290006be33SJames Wright 8300006be33SJames Wright 8310006be33SJames Wrightdef has_failures(test_suite: TestSuite) -> bool: 8320006be33SJames Wright """Check whether any test cases in a `TestSuite` failed 8330006be33SJames Wright 8340006be33SJames Wright Args: 8350006be33SJames Wright test_suite (TestSuite): JUnit `TestSuite` to check 8360006be33SJames Wright 8370006be33SJames Wright Returns: 8380006be33SJames Wright bool: True if any test cases failed 8390006be33SJames Wright """ 8400006be33SJames Wright return any(c.is_failure() or c.is_error() for c in test_suite.test_cases) 841