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