1*0006be33SJames Wrightfrom abc import ABC, abstractmethod 2*0006be33SJames Wrightimport argparse 3*0006be33SJames Wrightimport csv 4*0006be33SJames Wrightfrom dataclasses import dataclass, field 5*0006be33SJames Wrightimport difflib 6*0006be33SJames Wrightfrom enum import Enum 7*0006be33SJames Wrightfrom math import isclose 8*0006be33SJames Wrightimport os 9*0006be33SJames Wrightfrom pathlib import Path 10*0006be33SJames Wrightimport re 11*0006be33SJames Wrightimport subprocess 12*0006be33SJames Wrightimport multiprocessing as mp 13*0006be33SJames Wrightfrom itertools import product 14*0006be33SJames Wrightimport sys 15*0006be33SJames Wrightimport time 16*0006be33SJames Wrightfrom typing import Optional, Tuple, List 17*0006be33SJames Wright 18*0006be33SJames Wrightsys.path.insert(0, str(Path(__file__).parent / "junit-xml")) 19*0006be33SJames Wrightfrom junit_xml import TestCase, TestSuite, to_xml_report_string # nopep8 20*0006be33SJames Wright 21*0006be33SJames Wright 22*0006be33SJames Wrightclass CaseInsensitiveEnumAction(argparse.Action): 23*0006be33SJames Wright """Action to convert input values to lower case prior to converting to an Enum type""" 24*0006be33SJames Wright 25*0006be33SJames Wright def __init__(self, option_strings, dest, type, default, **kwargs): 26*0006be33SJames Wright if not (issubclass(type, Enum) and issubclass(type, str)): 27*0006be33SJames Wright raise ValueError(f"{type} must be a StrEnum or str and Enum") 28*0006be33SJames Wright # store provided enum type 29*0006be33SJames Wright self.enum_type = type 30*0006be33SJames Wright if isinstance(default, str): 31*0006be33SJames Wright default = self.enum_type(default.lower()) 32*0006be33SJames Wright else: 33*0006be33SJames Wright default = [self.enum_type(v.lower()) for v in default] 34*0006be33SJames Wright # prevent automatic type conversion 35*0006be33SJames Wright super().__init__(option_strings, dest, default=default, **kwargs) 36*0006be33SJames Wright 37*0006be33SJames Wright def __call__(self, parser, namespace, values, option_string=None): 38*0006be33SJames Wright if isinstance(values, str): 39*0006be33SJames Wright values = self.enum_type(values.lower()) 40*0006be33SJames Wright else: 41*0006be33SJames Wright values = [self.enum_type(v.lower()) for v in values] 42*0006be33SJames Wright setattr(namespace, self.dest, values) 43*0006be33SJames Wright 44*0006be33SJames Wright 45*0006be33SJames Wright@dataclass 46*0006be33SJames Wrightclass TestSpec: 47*0006be33SJames Wright """Dataclass storing information about a single test case""" 48*0006be33SJames Wright name: str 49*0006be33SJames Wright only: List = field(default_factory=list) 50*0006be33SJames Wright args: List = field(default_factory=list) 51*0006be33SJames Wright 52*0006be33SJames Wright 53*0006be33SJames Wrightclass RunMode(str, Enum): 54*0006be33SJames Wright """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`""" 55*0006be33SJames Wright __str__ = str.__str__ 56*0006be33SJames Wright __format__ = str.__format__ 57*0006be33SJames Wright TAP: str = 'tap' 58*0006be33SJames Wright JUNIT: str = 'junit' 59*0006be33SJames Wright 60*0006be33SJames Wright 61*0006be33SJames Wrightclass SuiteSpec(ABC): 62*0006be33SJames Wright """Abstract Base Class defining the required interface for running a test suite""" 63*0006be33SJames Wright @abstractmethod 64*0006be33SJames Wright def get_source_path(self, test: str) -> Path: 65*0006be33SJames Wright """Compute path to test source file 66*0006be33SJames Wright 67*0006be33SJames Wright Args: 68*0006be33SJames Wright test (str): Name of test 69*0006be33SJames Wright 70*0006be33SJames Wright Returns: 71*0006be33SJames Wright Path: Path to source file 72*0006be33SJames Wright """ 73*0006be33SJames Wright raise NotImplementedError 74*0006be33SJames Wright 75*0006be33SJames Wright @abstractmethod 76*0006be33SJames Wright def get_run_path(self, test: str) -> Path: 77*0006be33SJames Wright """Compute path to built test executable file 78*0006be33SJames Wright 79*0006be33SJames Wright Args: 80*0006be33SJames Wright test (str): Name of test 81*0006be33SJames Wright 82*0006be33SJames Wright Returns: 83*0006be33SJames Wright Path: Path to test executable 84*0006be33SJames Wright """ 85*0006be33SJames Wright raise NotImplementedError 86*0006be33SJames Wright 87*0006be33SJames Wright @abstractmethod 88*0006be33SJames Wright def get_output_path(self, test: str, output_file: str) -> Path: 89*0006be33SJames Wright """Compute path to expected output file 90*0006be33SJames Wright 91*0006be33SJames Wright Args: 92*0006be33SJames Wright test (str): Name of test 93*0006be33SJames Wright output_file (str): File name of output file 94*0006be33SJames Wright 95*0006be33SJames Wright Returns: 96*0006be33SJames Wright Path: Path to expected output file 97*0006be33SJames Wright """ 98*0006be33SJames Wright raise NotImplementedError 99*0006be33SJames Wright 100*0006be33SJames Wright @property 101*0006be33SJames Wright def cgns_tol(self): 102*0006be33SJames Wright """Absolute tolerance for CGNS diff""" 103*0006be33SJames Wright return getattr(self, '_cgns_tol', 1.0e-12) 104*0006be33SJames Wright 105*0006be33SJames Wright @cgns_tol.setter 106*0006be33SJames Wright def cgns_tol(self, val): 107*0006be33SJames Wright self._cgns_tol = val 108*0006be33SJames Wright 109*0006be33SJames Wright def post_test_hook(self, test: str, spec: TestSpec) -> None: 110*0006be33SJames Wright """Function callback ran after each test case 111*0006be33SJames Wright 112*0006be33SJames Wright Args: 113*0006be33SJames Wright test (str): Name of test 114*0006be33SJames Wright spec (TestSpec): Test case specification 115*0006be33SJames Wright """ 116*0006be33SJames Wright pass 117*0006be33SJames Wright 118*0006be33SJames Wright def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]: 119*0006be33SJames Wright """Check if a test case should be skipped prior to running, returning the reason for skipping 120*0006be33SJames Wright 121*0006be33SJames Wright Args: 122*0006be33SJames Wright test (str): Name of test 123*0006be33SJames Wright spec (TestSpec): Test case specification 124*0006be33SJames Wright resource (str): libCEED backend 125*0006be33SJames Wright nproc (int): Number of MPI processes to use when running test case 126*0006be33SJames Wright 127*0006be33SJames Wright Returns: 128*0006be33SJames Wright Optional[str]: Skip reason, or `None` if test case should not be skipped 129*0006be33SJames Wright """ 130*0006be33SJames Wright return None 131*0006be33SJames Wright 132*0006be33SJames Wright def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]: 133*0006be33SJames Wright """Check if a test case should be allowed to fail, based on its stderr output 134*0006be33SJames Wright 135*0006be33SJames Wright Args: 136*0006be33SJames Wright test (str): Name of test 137*0006be33SJames Wright spec (TestSpec): Test case specification 138*0006be33SJames Wright resource (str): libCEED backend 139*0006be33SJames Wright stderr (str): Standard error output from test case execution 140*0006be33SJames Wright 141*0006be33SJames Wright Returns: 142*0006be33SJames Wright Optional[str]: Skip reason, or `None` if unexpected error 143*0006be33SJames Wright """ 144*0006be33SJames Wright return None 145*0006be33SJames Wright 146*0006be33SJames Wright def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]: 147*0006be33SJames Wright """Check whether a test case is expected to fail and if it failed expectedly 148*0006be33SJames Wright 149*0006be33SJames Wright Args: 150*0006be33SJames Wright test (str): Name of test 151*0006be33SJames Wright spec (TestSpec): Test case specification 152*0006be33SJames Wright resource (str): libCEED backend 153*0006be33SJames Wright stderr (str): Standard error output from test case execution 154*0006be33SJames Wright 155*0006be33SJames Wright Returns: 156*0006be33SJames Wright tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr` 157*0006be33SJames Wright """ 158*0006be33SJames Wright return '', True 159*0006be33SJames Wright 160*0006be33SJames Wright def check_allowed_stdout(self, test: str) -> bool: 161*0006be33SJames Wright """Check whether a test is allowed to print console output 162*0006be33SJames Wright 163*0006be33SJames Wright Args: 164*0006be33SJames Wright test (str): Name of test 165*0006be33SJames Wright 166*0006be33SJames Wright Returns: 167*0006be33SJames Wright bool: True if the test is allowed to print console output 168*0006be33SJames Wright """ 169*0006be33SJames Wright return False 170*0006be33SJames Wright 171*0006be33SJames Wright 172*0006be33SJames Wrightdef has_cgnsdiff() -> bool: 173*0006be33SJames Wright """Check whether `cgnsdiff` is an executable program in the current environment 174*0006be33SJames Wright 175*0006be33SJames Wright Returns: 176*0006be33SJames Wright bool: True if `cgnsdiff` is found 177*0006be33SJames Wright """ 178*0006be33SJames Wright my_env: dict = os.environ.copy() 179*0006be33SJames Wright proc = subprocess.run('cgnsdiff', 180*0006be33SJames Wright shell=True, 181*0006be33SJames Wright stdout=subprocess.PIPE, 182*0006be33SJames Wright stderr=subprocess.PIPE, 183*0006be33SJames Wright env=my_env) 184*0006be33SJames Wright return 'not found' not in proc.stderr.decode('utf-8') 185*0006be33SJames Wright 186*0006be33SJames Wright 187*0006be33SJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool: 188*0006be33SJames Wright """Helper function, checks if any of the substrings are included in the base string 189*0006be33SJames Wright 190*0006be33SJames Wright Args: 191*0006be33SJames Wright base (str): Base string to search in 192*0006be33SJames Wright substrings (List[str]): List of potential substrings 193*0006be33SJames Wright 194*0006be33SJames Wright Returns: 195*0006be33SJames Wright bool: True if any substrings are included in base string 196*0006be33SJames Wright """ 197*0006be33SJames Wright return any((sub in base for sub in substrings)) 198*0006be33SJames Wright 199*0006be33SJames Wright 200*0006be33SJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool: 201*0006be33SJames Wright """Helper function, checks if the base string is prefixed by any of `prefixes` 202*0006be33SJames Wright 203*0006be33SJames Wright Args: 204*0006be33SJames Wright base (str): Base string to search 205*0006be33SJames Wright prefixes (List[str]): List of potential prefixes 206*0006be33SJames Wright 207*0006be33SJames Wright Returns: 208*0006be33SJames Wright bool: True if base string is prefixed by any of the prefixes 209*0006be33SJames Wright """ 210*0006be33SJames Wright return any((base.startswith(prefix) for prefix in prefixes)) 211*0006be33SJames Wright 212*0006be33SJames Wright 213*0006be33SJames Wrightdef parse_test_line(line: str) -> TestSpec: 214*0006be33SJames Wright """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object 215*0006be33SJames Wright 216*0006be33SJames Wright Args: 217*0006be33SJames Wright line (str): String containing TESTARGS specification and CLI arguments 218*0006be33SJames Wright 219*0006be33SJames Wright Returns: 220*0006be33SJames Wright TestSpec: Parsed specification of test case 221*0006be33SJames Wright """ 222*0006be33SJames Wright args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip()) 223*0006be33SJames Wright if args[0] == 'TESTARGS': 224*0006be33SJames Wright return TestSpec(name='', args=args[1:]) 225*0006be33SJames Wright raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')] 226*0006be33SJames Wright # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'} 227*0006be33SJames Wright test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)]) 228*0006be33SJames Wright name: str = test_args.get('name', '') 229*0006be33SJames Wright constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else [] 230*0006be33SJames Wright if len(args) > 1: 231*0006be33SJames Wright return TestSpec(name=name, only=constraints, args=args[1:]) 232*0006be33SJames Wright else: 233*0006be33SJames Wright return TestSpec(name=name, only=constraints) 234*0006be33SJames Wright 235*0006be33SJames Wright 236*0006be33SJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]: 237*0006be33SJames Wright """Parse all test cases from a given source file 238*0006be33SJames Wright 239*0006be33SJames Wright Args: 240*0006be33SJames Wright source_file (Path): Path to source file 241*0006be33SJames Wright 242*0006be33SJames Wright Raises: 243*0006be33SJames Wright RuntimeError: Errors if source file extension is unsupported 244*0006be33SJames Wright 245*0006be33SJames Wright Returns: 246*0006be33SJames Wright List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found 247*0006be33SJames Wright """ 248*0006be33SJames Wright comment_str: str = '' 249*0006be33SJames Wright if source_file.suffix in ['.c', '.cc', '.cpp']: 250*0006be33SJames Wright comment_str = '//' 251*0006be33SJames Wright elif source_file.suffix in ['.py']: 252*0006be33SJames Wright comment_str = '#' 253*0006be33SJames Wright elif source_file.suffix in ['.usr']: 254*0006be33SJames Wright comment_str = 'C_' 255*0006be33SJames Wright elif source_file.suffix in ['.f90']: 256*0006be33SJames Wright comment_str = '! ' 257*0006be33SJames Wright else: 258*0006be33SJames Wright raise RuntimeError(f'Unrecognized extension for file: {source_file}') 259*0006be33SJames Wright 260*0006be33SJames Wright return [parse_test_line(line.strip(comment_str)) 261*0006be33SJames Wright for line in source_file.read_text().splitlines() 262*0006be33SJames Wright if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])] 263*0006be33SJames Wright 264*0006be33SJames Wright 265*0006be33SJames Wrightdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2) -> str: 266*0006be33SJames Wright """Compare CSV results against an expected CSV file with tolerances 267*0006be33SJames Wright 268*0006be33SJames Wright Args: 269*0006be33SJames Wright test_csv (Path): Path to output CSV results 270*0006be33SJames Wright true_csv (Path): Path to expected CSV results 271*0006be33SJames Wright zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10. 272*0006be33SJames Wright rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2. 273*0006be33SJames Wright 274*0006be33SJames Wright Returns: 275*0006be33SJames Wright str: Diff output between result and expected CSVs 276*0006be33SJames Wright """ 277*0006be33SJames Wright test_lines: List[str] = test_csv.read_text().splitlines() 278*0006be33SJames Wright true_lines: List[str] = true_csv.read_text().splitlines() 279*0006be33SJames Wright # Files should not be empty 280*0006be33SJames Wright if len(test_lines) == 0: 281*0006be33SJames Wright return f'No lines found in test output {test_csv}' 282*0006be33SJames Wright if len(true_lines) == 0: 283*0006be33SJames Wright return f'No lines found in test source {true_csv}' 284*0006be33SJames Wright 285*0006be33SJames Wright test_reader: csv.DictReader = csv.DictReader(test_lines) 286*0006be33SJames Wright true_reader: csv.DictReader = csv.DictReader(true_lines) 287*0006be33SJames Wright if test_reader.fieldnames != true_reader.fieldnames: 288*0006be33SJames Wright return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'], 289*0006be33SJames Wright tofile='found CSV columns', fromfile='expected CSV columns')) 290*0006be33SJames Wright 291*0006be33SJames Wright if len(test_lines) != len(true_lines): 292*0006be33SJames Wright return f'Number of lines in {test_csv} and {true_csv} do not match' 293*0006be33SJames Wright diff_lines: List[str] = list() 294*0006be33SJames Wright for test_line, true_line in zip(test_reader, true_reader): 295*0006be33SJames Wright for key in test_reader.fieldnames: 296*0006be33SJames Wright # Check if the value is numeric 297*0006be33SJames Wright try: 298*0006be33SJames Wright true_val: float = float(true_line[key]) 299*0006be33SJames Wright test_val: float = float(test_line[key]) 300*0006be33SJames Wright true_zero: bool = abs(true_val) < zero_tol 301*0006be33SJames Wright test_zero: bool = abs(test_val) < zero_tol 302*0006be33SJames Wright fail: bool = False 303*0006be33SJames Wright if true_zero: 304*0006be33SJames Wright fail = not test_zero 305*0006be33SJames Wright else: 306*0006be33SJames Wright fail = not isclose(test_val, true_val, rel_tol=rel_tol) 307*0006be33SJames Wright if fail: 308*0006be33SJames Wright diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}') 309*0006be33SJames Wright except ValueError: 310*0006be33SJames Wright if test_line[key] != true_line[key]: 311*0006be33SJames Wright diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}') 312*0006be33SJames Wright 313*0006be33SJames Wright return '\n'.join(diff_lines) 314*0006be33SJames Wright 315*0006be33SJames Wright 316*0006be33SJames Wrightdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float = 1e-12) -> str: 317*0006be33SJames Wright """Compare CGNS results against an expected CGSN file with tolerance 318*0006be33SJames Wright 319*0006be33SJames Wright Args: 320*0006be33SJames Wright test_cgns (Path): Path to output CGNS file 321*0006be33SJames Wright true_cgns (Path): Path to expected CGNS file 322*0006be33SJames Wright cgns_tol (float, optional): Tolerance for comparing floating-point values 323*0006be33SJames Wright 324*0006be33SJames Wright Returns: 325*0006be33SJames Wright str: Diff output between result and expected CGNS files 326*0006be33SJames Wright """ 327*0006be33SJames Wright my_env: dict = os.environ.copy() 328*0006be33SJames Wright 329*0006be33SJames Wright run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)] 330*0006be33SJames Wright proc = subprocess.run(' '.join(run_args), 331*0006be33SJames Wright shell=True, 332*0006be33SJames Wright stdout=subprocess.PIPE, 333*0006be33SJames Wright stderr=subprocess.PIPE, 334*0006be33SJames Wright env=my_env) 335*0006be33SJames Wright 336*0006be33SJames Wright return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8') 337*0006be33SJames Wright 338*0006be33SJames Wright 339*0006be33SJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode, 340*0006be33SJames Wright backend: str, test: str, index: int) -> str: 341*0006be33SJames Wright output_str = '' 342*0006be33SJames Wright if mode is RunMode.TAP: 343*0006be33SJames Wright # print incremental output if TAP mode 344*0006be33SJames Wright if test_case.is_skipped(): 345*0006be33SJames Wright output_str += f' ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n' 346*0006be33SJames Wright elif test_case.is_failure() or test_case.is_error(): 347*0006be33SJames Wright output_str += f' not ok {index} - {spec.name}, {backend}\n' 348*0006be33SJames Wright else: 349*0006be33SJames Wright output_str += f' ok {index} - {spec.name}, {backend}\n' 350*0006be33SJames Wright output_str += f' ---\n' 351*0006be33SJames Wright if spec.only: 352*0006be33SJames Wright output_str += f' only: {",".join(spec.only)}\n' 353*0006be33SJames Wright output_str += f' args: {test_case.args}\n' 354*0006be33SJames Wright if test_case.is_error(): 355*0006be33SJames Wright output_str += f' error: {test_case.errors[0]["message"]}\n' 356*0006be33SJames Wright if test_case.is_failure(): 357*0006be33SJames Wright output_str += f' num_failures: {len(test_case.failures)}\n' 358*0006be33SJames Wright for i, failure in enumerate(test_case.failures): 359*0006be33SJames Wright output_str += f' failure_{i}: {failure["message"]}\n' 360*0006be33SJames Wright output_str += f' message: {failure["message"]}\n' 361*0006be33SJames Wright if failure["output"]: 362*0006be33SJames Wright out = failure["output"].strip().replace('\n', '\n ') 363*0006be33SJames Wright output_str += f' output: |\n {out}\n' 364*0006be33SJames Wright output_str += f' ...\n' 365*0006be33SJames Wright else: 366*0006be33SJames Wright # print error or failure information if JUNIT mode 367*0006be33SJames Wright if test_case.is_error() or test_case.is_failure(): 368*0006be33SJames Wright output_str += f'Test: {test} {spec.name}\n' 369*0006be33SJames Wright output_str += f' $ {test_case.args}\n' 370*0006be33SJames Wright if test_case.is_error(): 371*0006be33SJames Wright output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()) 372*0006be33SJames Wright output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()) 373*0006be33SJames Wright if test_case.is_failure(): 374*0006be33SJames Wright for failure in test_case.failures: 375*0006be33SJames Wright output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip()) 376*0006be33SJames Wright output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip()) 377*0006be33SJames Wright return output_str 378*0006be33SJames Wright 379*0006be33SJames Wright 380*0006be33SJames Wrightdef run_test(index: int, test: str, spec: TestSpec, backend: str, 381*0006be33SJames Wright mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase: 382*0006be33SJames Wright """Run a single test case and backend combination 383*0006be33SJames Wright 384*0006be33SJames Wright Args: 385*0006be33SJames Wright index (int): Index of backend for current spec 386*0006be33SJames Wright test (str): Path to test 387*0006be33SJames Wright spec (TestSpec): Specification of test case 388*0006be33SJames Wright backend (str): CEED backend 389*0006be33SJames Wright mode (RunMode): Output mode 390*0006be33SJames Wright nproc (int): Number of MPI processes to use when running test case 391*0006be33SJames Wright suite_spec (SuiteSpec): Specification of test suite 392*0006be33SJames Wright 393*0006be33SJames Wright Returns: 394*0006be33SJames Wright TestCase: Test case result 395*0006be33SJames Wright """ 396*0006be33SJames Wright source_path: Path = suite_spec.get_source_path(test) 397*0006be33SJames Wright run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)] 398*0006be33SJames Wright 399*0006be33SJames Wright if '{ceed_resource}' in run_args: 400*0006be33SJames Wright run_args[run_args.index('{ceed_resource}')] = backend 401*0006be33SJames Wright for i, arg in enumerate(run_args): 402*0006be33SJames Wright if '{ceed_resource}' in arg: 403*0006be33SJames Wright run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-')) 404*0006be33SJames Wright if '{nproc}' in run_args: 405*0006be33SJames Wright run_args[run_args.index('{nproc}')] = f'{nproc}' 406*0006be33SJames Wright elif nproc > 1 and source_path.suffix != '.py': 407*0006be33SJames Wright run_args = ['mpiexec', '-n', f'{nproc}', *run_args] 408*0006be33SJames Wright 409*0006be33SJames Wright # run test 410*0006be33SJames Wright skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc) 411*0006be33SJames Wright if skip_reason: 412*0006be33SJames Wright test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 413*0006be33SJames Wright elapsed_sec=0, 414*0006be33SJames Wright timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()), 415*0006be33SJames Wright stdout='', 416*0006be33SJames Wright stderr='', 417*0006be33SJames Wright category=spec.name,) 418*0006be33SJames Wright test_case.add_skipped_info(skip_reason) 419*0006be33SJames Wright else: 420*0006be33SJames Wright start: float = time.time() 421*0006be33SJames Wright proc = subprocess.run(' '.join(str(arg) for arg in run_args), 422*0006be33SJames Wright shell=True, 423*0006be33SJames Wright stdout=subprocess.PIPE, 424*0006be33SJames Wright stderr=subprocess.PIPE, 425*0006be33SJames Wright env=my_env) 426*0006be33SJames Wright 427*0006be33SJames Wright test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}', 428*0006be33SJames Wright classname=source_path.parent, 429*0006be33SJames Wright elapsed_sec=time.time() - start, 430*0006be33SJames Wright timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)), 431*0006be33SJames Wright stdout=proc.stdout.decode('utf-8'), 432*0006be33SJames Wright stderr=proc.stderr.decode('utf-8'), 433*0006be33SJames Wright allow_multiple_subelements=True, 434*0006be33SJames Wright category=spec.name,) 435*0006be33SJames Wright ref_csvs: List[Path] = [] 436*0006be33SJames Wright output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg] 437*0006be33SJames Wright if output_files: 438*0006be33SJames Wright ref_csvs = [suite_spec.get_output_path(test, file.split('ascii:')[-1]) for file in output_files] 439*0006be33SJames Wright ref_cgns: List[Path] = [] 440*0006be33SJames Wright output_files = [arg for arg in run_args if 'cgns:' in arg] 441*0006be33SJames Wright if output_files: 442*0006be33SJames Wright ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files] 443*0006be33SJames Wright ref_stdout: Path = suite_spec.get_output_path(test, test + '.out') 444*0006be33SJames Wright suite_spec.post_test_hook(test, spec) 445*0006be33SJames Wright 446*0006be33SJames Wright # check allowed failures 447*0006be33SJames Wright if not test_case.is_skipped() and test_case.stderr: 448*0006be33SJames Wright skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr) 449*0006be33SJames Wright if skip_reason: 450*0006be33SJames Wright test_case.add_skipped_info(skip_reason) 451*0006be33SJames Wright 452*0006be33SJames Wright # check required failures 453*0006be33SJames Wright if not test_case.is_skipped(): 454*0006be33SJames Wright required_message, did_fail = suite_spec.check_required_failure( 455*0006be33SJames Wright test, spec, backend, test_case.stderr) 456*0006be33SJames Wright if required_message and did_fail: 457*0006be33SJames Wright test_case.status = f'fails with required: {required_message}' 458*0006be33SJames Wright elif required_message: 459*0006be33SJames Wright test_case.add_failure_info(f'required failure missing: {required_message}') 460*0006be33SJames Wright 461*0006be33SJames Wright # classify other results 462*0006be33SJames Wright if not test_case.is_skipped() and not test_case.status: 463*0006be33SJames Wright if test_case.stderr: 464*0006be33SJames Wright test_case.add_failure_info('stderr', test_case.stderr) 465*0006be33SJames Wright if proc.returncode != 0: 466*0006be33SJames Wright test_case.add_error_info(f'returncode = {proc.returncode}') 467*0006be33SJames Wright if ref_stdout.is_file(): 468*0006be33SJames Wright diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True), 469*0006be33SJames Wright test_case.stdout.splitlines(keepends=True), 470*0006be33SJames Wright fromfile=str(ref_stdout), 471*0006be33SJames Wright tofile='New')) 472*0006be33SJames Wright if diff: 473*0006be33SJames Wright test_case.add_failure_info('stdout', output=''.join(diff)) 474*0006be33SJames Wright elif test_case.stdout and not suite_spec.check_allowed_stdout(test): 475*0006be33SJames Wright test_case.add_failure_info('stdout', output=test_case.stdout) 476*0006be33SJames Wright # expected CSV output 477*0006be33SJames Wright for ref_csv in ref_csvs: 478*0006be33SJames Wright csv_name = ref_csv.name 479*0006be33SJames Wright if not ref_csv.is_file(): 480*0006be33SJames Wright # remove _{ceed_backend} from path name 481*0006be33SJames Wright ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv') 482*0006be33SJames Wright if not ref_csv.is_file(): 483*0006be33SJames Wright test_case.add_failure_info('csv', output=f'{ref_csv} not found') 484*0006be33SJames Wright else: 485*0006be33SJames Wright diff: str = diff_csv(Path.cwd() / csv_name, ref_csv) 486*0006be33SJames Wright if diff: 487*0006be33SJames Wright test_case.add_failure_info('csv', output=diff) 488*0006be33SJames Wright else: 489*0006be33SJames Wright (Path.cwd() / csv_name).unlink() 490*0006be33SJames Wright # expected CGNS output 491*0006be33SJames Wright for ref_cgn in ref_cgns: 492*0006be33SJames Wright cgn_name = ref_cgn.name 493*0006be33SJames Wright if not ref_cgn.is_file(): 494*0006be33SJames Wright # remove _{ceed_backend} from path name 495*0006be33SJames Wright ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns') 496*0006be33SJames Wright if not ref_cgn.is_file(): 497*0006be33SJames Wright test_case.add_failure_info('cgns', output=f'{ref_cgn} not found') 498*0006be33SJames Wright else: 499*0006be33SJames Wright diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn, cgns_tol=suite_spec.cgns_tol) 500*0006be33SJames Wright if diff: 501*0006be33SJames Wright test_case.add_failure_info('cgns', output=diff) 502*0006be33SJames Wright else: 503*0006be33SJames Wright (Path.cwd() / cgn_name).unlink() 504*0006be33SJames Wright 505*0006be33SJames Wright # store result 506*0006be33SJames Wright test_case.args = ' '.join(str(arg) for arg in run_args) 507*0006be33SJames Wright output_str = test_case_output_string(test_case, spec, mode, backend, test, index) 508*0006be33SJames Wright 509*0006be33SJames Wright return test_case, output_str 510*0006be33SJames Wright 511*0006be33SJames Wright 512*0006be33SJames Wrightdef init_process(): 513*0006be33SJames Wright """Initialize multiprocessing process""" 514*0006be33SJames Wright # set up error handler 515*0006be33SJames Wright global my_env 516*0006be33SJames Wright my_env = os.environ.copy() 517*0006be33SJames Wright my_env['CEED_ERROR_HANDLER'] = 'exit' 518*0006be33SJames Wright 519*0006be33SJames Wright 520*0006be33SJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int, 521*0006be33SJames Wright suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite: 522*0006be33SJames Wright """Run all test cases for `test` with each of the provided `ceed_backends` 523*0006be33SJames Wright 524*0006be33SJames Wright Args: 525*0006be33SJames Wright test (str): Name of test 526*0006be33SJames Wright ceed_backends (List[str]): List of libCEED backends 527*0006be33SJames Wright mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT` 528*0006be33SJames Wright nproc (int): Number of MPI processes to use when running each test case 529*0006be33SJames Wright suite_spec (SuiteSpec): Object defining required methods for running tests 530*0006be33SJames Wright pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1. 531*0006be33SJames Wright 532*0006be33SJames Wright Returns: 533*0006be33SJames Wright TestSuite: JUnit `TestSuite` containing results of all test cases 534*0006be33SJames Wright """ 535*0006be33SJames Wright test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test)) 536*0006be33SJames Wright if mode is RunMode.TAP: 537*0006be33SJames Wright print('TAP version 13') 538*0006be33SJames Wright print(f'1..{len(test_specs)}') 539*0006be33SJames Wright 540*0006be33SJames Wright with mp.Pool(processes=pool_size, initializer=init_process) as pool: 541*0006be33SJames Wright async_outputs: List[List[mp.AsyncResult]] = [ 542*0006be33SJames Wright [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec)) 543*0006be33SJames Wright for (i, backend) in enumerate(ceed_backends, start=1)] 544*0006be33SJames Wright for spec in test_specs 545*0006be33SJames Wright ] 546*0006be33SJames Wright 547*0006be33SJames Wright test_cases = [] 548*0006be33SJames Wright for (i, subtest) in enumerate(async_outputs, start=1): 549*0006be33SJames Wright is_new_subtest = True 550*0006be33SJames Wright subtest_ok = True 551*0006be33SJames Wright for async_output in subtest: 552*0006be33SJames Wright test_case, print_output = async_output.get() 553*0006be33SJames Wright test_cases.append(test_case) 554*0006be33SJames Wright if is_new_subtest and mode == RunMode.TAP: 555*0006be33SJames Wright is_new_subtest = False 556*0006be33SJames Wright print(f'# Subtest: {test_case.category}') 557*0006be33SJames Wright print(f' 1..{len(ceed_backends)}') 558*0006be33SJames Wright print(print_output, end='') 559*0006be33SJames Wright if test_case.is_failure() or test_case.is_error(): 560*0006be33SJames Wright subtest_ok = False 561*0006be33SJames Wright if mode == RunMode.TAP: 562*0006be33SJames Wright print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}') 563*0006be33SJames Wright 564*0006be33SJames Wright return TestSuite(test, test_cases) 565*0006be33SJames Wright 566*0006be33SJames Wright 567*0006be33SJames Wrightdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None: 568*0006be33SJames Wright """Write a JUnit XML file containing the results of a `TestSuite` 569*0006be33SJames Wright 570*0006be33SJames Wright Args: 571*0006be33SJames Wright test_suite (TestSuite): JUnit `TestSuite` to write 572*0006be33SJames Wright output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit` 573*0006be33SJames Wright batch (str): Name of JUnit batch, defaults to empty string 574*0006be33SJames Wright """ 575*0006be33SJames Wright output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit') 576*0006be33SJames Wright output_file.write_text(to_xml_report_string([test_suite])) 577*0006be33SJames Wright 578*0006be33SJames Wright 579*0006be33SJames Wrightdef has_failures(test_suite: TestSuite) -> bool: 580*0006be33SJames Wright """Check whether any test cases in a `TestSuite` failed 581*0006be33SJames Wright 582*0006be33SJames Wright Args: 583*0006be33SJames Wright test_suite (TestSuite): JUnit `TestSuite` to check 584*0006be33SJames Wright 585*0006be33SJames Wright Returns: 586*0006be33SJames Wright bool: True if any test cases failed 587*0006be33SJames Wright """ 588*0006be33SJames Wright return any(c.is_failure() or c.is_error() for c in test_suite.test_cases) 589