xref: /libCEED/tests/junit_common.py (revision 2fee3251344e325ffa4c1e5d9ff42b3fc5f53b0c)
11b16049aSZach Atkinsfrom abc import ABC, abstractmethod
21b16049aSZach Atkinsimport argparse
31b16049aSZach Atkinsfrom dataclasses import dataclass, field
41b16049aSZach Atkinsimport difflib
51b16049aSZach Atkinsfrom enum import Enum
61b16049aSZach Atkinsfrom math import isclose
71b16049aSZach Atkinsimport os
81b16049aSZach Atkinsfrom pathlib import Path
91b16049aSZach Atkinsimport re
101b16049aSZach Atkinsimport subprocess
111b16049aSZach Atkinsimport sys
121b16049aSZach Atkinsimport time
131b16049aSZach Atkinsfrom typing import Optional
141b16049aSZach Atkins
151b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
161b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
171b16049aSZach Atkins
181b16049aSZach Atkins
191b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action):
201b16049aSZach Atkins    """Action to convert input values to lower case prior to converting to an Enum type"""
211b16049aSZach Atkins
221b16049aSZach Atkins    def __init__(self, option_strings, dest, type, default, **kwargs):
231b16049aSZach Atkins        if not (issubclass(type, Enum) and issubclass(type, str)):
241b16049aSZach Atkins            raise ValueError(f"{type} must be a StrEnum or str and Enum")
251b16049aSZach Atkins        # store provided enum type
261b16049aSZach Atkins        self.enum_type = type
271b16049aSZach Atkins        if isinstance(default, str):
281b16049aSZach Atkins            default = self.enum_type(default.lower())
291b16049aSZach Atkins        else:
301b16049aSZach Atkins            default = [self.enum_type(v.lower()) for v in default]
311b16049aSZach Atkins        # prevent automatic type conversion
321b16049aSZach Atkins        super().__init__(option_strings, dest, default=default, **kwargs)
331b16049aSZach Atkins
341b16049aSZach Atkins    def __call__(self, parser, namespace, values, option_string=None):
351b16049aSZach Atkins        if isinstance(values, str):
361b16049aSZach Atkins            values = self.enum_type(values.lower())
371b16049aSZach Atkins        else:
381b16049aSZach Atkins            values = [self.enum_type(v.lower()) for v in values]
391b16049aSZach Atkins        setattr(namespace, self.dest, values)
401b16049aSZach Atkins
411b16049aSZach Atkins
421b16049aSZach Atkins@dataclass
431b16049aSZach Atkinsclass TestSpec:
441b16049aSZach Atkins    """Dataclass storing information about a single test case"""
451b16049aSZach Atkins    name: str
461b16049aSZach Atkins    only: list = field(default_factory=list)
471b16049aSZach Atkins    args: list = field(default_factory=list)
481b16049aSZach Atkins
491b16049aSZach Atkins
501b16049aSZach Atkinsclass RunMode(str, Enum):
511b16049aSZach Atkins    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
521b16049aSZach Atkins    __str__ = str.__str__
531b16049aSZach Atkins    __format__ = str.__format__
541b16049aSZach Atkins    TAP: str = 'tap'
551b16049aSZach Atkins    JUNIT: str = 'junit'
561b16049aSZach Atkins
571b16049aSZach Atkins
581b16049aSZach Atkinsclass SuiteSpec(ABC):
591b16049aSZach Atkins    """Abstract Base Class defining the required interface for running a test suite"""
601b16049aSZach Atkins    @abstractmethod
611b16049aSZach Atkins    def get_source_path(self, test: str) -> Path:
621b16049aSZach Atkins        """Compute path to test source file
631b16049aSZach Atkins
641b16049aSZach Atkins        Args:
651b16049aSZach Atkins            test (str): Name of test
661b16049aSZach Atkins
671b16049aSZach Atkins        Returns:
681b16049aSZach Atkins            Path: Path to source file
691b16049aSZach Atkins        """
701b16049aSZach Atkins        raise NotImplementedError
711b16049aSZach Atkins
721b16049aSZach Atkins    @abstractmethod
731b16049aSZach Atkins    def get_run_path(self, test: str) -> Path:
741b16049aSZach Atkins        """Compute path to built test executable file
751b16049aSZach Atkins
761b16049aSZach Atkins        Args:
771b16049aSZach Atkins            test (str): Name of test
781b16049aSZach Atkins
791b16049aSZach Atkins        Returns:
801b16049aSZach Atkins            Path: Path to test executable
811b16049aSZach Atkins        """
821b16049aSZach Atkins        raise NotImplementedError
831b16049aSZach Atkins
841b16049aSZach Atkins    @abstractmethod
851b16049aSZach Atkins    def get_output_path(self, test: str, output_file: str) -> Path:
861b16049aSZach Atkins        """Compute path to expected output file
871b16049aSZach Atkins
881b16049aSZach Atkins        Args:
891b16049aSZach Atkins            test (str): Name of test
901b16049aSZach Atkins            output_file (str): File name of output file
911b16049aSZach Atkins
921b16049aSZach Atkins        Returns:
931b16049aSZach Atkins            Path: Path to expected output file
941b16049aSZach Atkins        """
951b16049aSZach Atkins        raise NotImplementedError
961b16049aSZach Atkins
971b16049aSZach Atkins    def post_test_hook(self, test: str, spec: TestSpec) -> None:
981b16049aSZach Atkins        """Function callback ran after each test case
991b16049aSZach Atkins
1001b16049aSZach Atkins        Args:
1011b16049aSZach Atkins            test (str): Name of test
1021b16049aSZach Atkins            spec (TestSpec): Test case specification
1031b16049aSZach Atkins        """
1041b16049aSZach Atkins        pass
1051b16049aSZach Atkins
1061b16049aSZach Atkins    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1071b16049aSZach Atkins        """Check if a test case should be skipped prior to running, returning the reason for skipping
1081b16049aSZach Atkins
1091b16049aSZach Atkins        Args:
1101b16049aSZach Atkins            test (str): Name of test
1111b16049aSZach Atkins            spec (TestSpec): Test case specification
1121b16049aSZach Atkins            resource (str): libCEED backend
1131b16049aSZach Atkins            nproc (int): Number of MPI processes to use when running test case
1141b16049aSZach Atkins
1151b16049aSZach Atkins        Returns:
1161b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if test case should not be skipped
1171b16049aSZach Atkins        """
1181b16049aSZach Atkins        return None
1191b16049aSZach Atkins
1201b16049aSZach Atkins    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1211b16049aSZach Atkins        """Check if a test case should be allowed to fail, based on its stderr output
1221b16049aSZach Atkins
1231b16049aSZach Atkins        Args:
1241b16049aSZach Atkins            test (str): Name of test
1251b16049aSZach Atkins            spec (TestSpec): Test case specification
1261b16049aSZach Atkins            resource (str): libCEED backend
1271b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1281b16049aSZach Atkins
1291b16049aSZach Atkins        Returns:
1301b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if unexpeced error
1311b16049aSZach Atkins        """
1321b16049aSZach Atkins        return None
1331b16049aSZach Atkins
1341b16049aSZach Atkins    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> tuple[str, bool]:
1351b16049aSZach Atkins        """Check whether a test case is expected to fail and if it failed expectedly
1361b16049aSZach Atkins
1371b16049aSZach Atkins        Args:
1381b16049aSZach Atkins            test (str): Name of test
1391b16049aSZach Atkins            spec (TestSpec): Test case specification
1401b16049aSZach Atkins            resource (str): libCEED backend
1411b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1421b16049aSZach Atkins
1431b16049aSZach Atkins        Returns:
1441b16049aSZach Atkins            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
1451b16049aSZach Atkins        """
1461b16049aSZach Atkins        return '', True
1471b16049aSZach Atkins
1481b16049aSZach Atkins    def check_allowed_stdout(self, test: str) -> bool:
1491b16049aSZach Atkins        """Check whether a test is allowed to print console output
1501b16049aSZach Atkins
1511b16049aSZach Atkins        Args:
1521b16049aSZach Atkins            test (str): Name of test
1531b16049aSZach Atkins
1541b16049aSZach Atkins        Returns:
1551b16049aSZach Atkins            bool: True if the test is allowed to print console output
1561b16049aSZach Atkins        """
1571b16049aSZach Atkins        return False
1581b16049aSZach Atkins
1591b16049aSZach Atkins
1601b16049aSZach Atkinsdef has_cgnsdiff() -> bool:
1611b16049aSZach Atkins    """Check whether `cgnsdiff` is an executable program in the current environment
1621b16049aSZach Atkins
1631b16049aSZach Atkins    Returns:
1641b16049aSZach Atkins        bool: True if `cgnsdiff` is found
1651b16049aSZach Atkins    """
1661b16049aSZach Atkins    my_env: dict = os.environ.copy()
1671b16049aSZach Atkins    proc = subprocess.run('cgnsdiff',
1681b16049aSZach Atkins                          shell=True,
1691b16049aSZach Atkins                          stdout=subprocess.PIPE,
1701b16049aSZach Atkins                          stderr=subprocess.PIPE,
1711b16049aSZach Atkins                          env=my_env)
1721b16049aSZach Atkins    return 'not found' not in proc.stderr.decode('utf-8')
1731b16049aSZach Atkins
1741b16049aSZach Atkins
1751b16049aSZach Atkinsdef contains_any(base: str, substrings: list[str]) -> bool:
1761b16049aSZach Atkins    """Helper function, checks if any of the substrings are included in the base string
1771b16049aSZach Atkins
1781b16049aSZach Atkins    Args:
1791b16049aSZach Atkins        base (str): Base string to search in
1801b16049aSZach Atkins        substrings (list[str]): List of potential substrings
1811b16049aSZach Atkins
1821b16049aSZach Atkins    Returns:
1831b16049aSZach Atkins        bool: True if any substrings are included in base string
1841b16049aSZach Atkins    """
1851b16049aSZach Atkins    return any((sub in base for sub in substrings))
1861b16049aSZach Atkins
1871b16049aSZach Atkins
1881b16049aSZach Atkinsdef startswith_any(base: str, prefixes: list[str]) -> bool:
1891b16049aSZach Atkins    """Helper function, checks if the base string is prefixed by any of `prefixes`
1901b16049aSZach Atkins
1911b16049aSZach Atkins    Args:
1921b16049aSZach Atkins        base (str): Base string to search
1931b16049aSZach Atkins        prefixes (list[str]): List of potential prefixes
1941b16049aSZach Atkins
1951b16049aSZach Atkins    Returns:
1961b16049aSZach Atkins        bool: True if base string is prefixed by any of the prefixes
1971b16049aSZach Atkins    """
1981b16049aSZach Atkins    return any((base.startswith(prefix) for prefix in prefixes))
1991b16049aSZach Atkins
2001b16049aSZach Atkins
2011b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec:
2021b16049aSZach Atkins    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
2031b16049aSZach Atkins
2041b16049aSZach Atkins    Args:
2051b16049aSZach Atkins        line (str): String containing TESTARGS specification and CLI arguments
2061b16049aSZach Atkins
2071b16049aSZach Atkins    Returns:
2081b16049aSZach Atkins        TestSpec: Parsed specification of test case
2091b16049aSZach Atkins    """
2101b16049aSZach Atkins    args: list[str] = re.findall("(?:\".*?\"|\\S)+", line.strip())
2111b16049aSZach Atkins    if args[0] == 'TESTARGS':
2121b16049aSZach Atkins        return TestSpec(name='', args=args[1:])
2131b16049aSZach Atkins    raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')]
2141b16049aSZach Atkins    # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'}
2151b16049aSZach Atkins    test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)])
216f85e4a7bSJeremy L Thompson    name: str = test_args.get('name', '')
2171b16049aSZach Atkins    constraints: list[str] = test_args['only'].split(',') if 'only' in test_args else []
2181b16049aSZach Atkins    if len(args) > 1:
219f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints, args=args[1:])
2201b16049aSZach Atkins    else:
221f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints)
2221b16049aSZach Atkins
2231b16049aSZach Atkins
2241b16049aSZach Atkinsdef get_test_args(source_file: Path) -> list[TestSpec]:
2251b16049aSZach Atkins    """Parse all test cases from a given source file
2261b16049aSZach Atkins
2271b16049aSZach Atkins    Args:
2281b16049aSZach Atkins        source_file (Path): Path to source file
2291b16049aSZach Atkins
2301b16049aSZach Atkins    Raises:
2311b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
2321b16049aSZach Atkins
2331b16049aSZach Atkins    Returns:
2341b16049aSZach Atkins        list[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
2351b16049aSZach Atkins    """
2361b16049aSZach Atkins    comment_str: str = ''
2371b16049aSZach Atkins    if source_file.suffix in ['.c', '.cpp']:
2381b16049aSZach Atkins        comment_str = '//'
2391b16049aSZach Atkins    elif source_file.suffix in ['.py']:
2401b16049aSZach Atkins        comment_str = '#'
2411b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
2421b16049aSZach Atkins        comment_str = 'C_'
2431b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
2441b16049aSZach Atkins        comment_str = '! '
2451b16049aSZach Atkins    else:
2461b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
2471b16049aSZach Atkins
2481b16049aSZach Atkins    return [parse_test_line(line.strip(comment_str))
2491b16049aSZach Atkins            for line in source_file.read_text().splitlines()
2501b16049aSZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
2511b16049aSZach Atkins
2521b16049aSZach Atkins
2531b16049aSZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2) -> str:
2541b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
2551b16049aSZach Atkins
2561b16049aSZach Atkins    Args:
2571b16049aSZach Atkins        test_csv (Path): Path to output CSV results
2581b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
2591b16049aSZach Atkins        zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10.
2601b16049aSZach Atkins        rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2.
2611b16049aSZach Atkins
2621b16049aSZach Atkins    Returns:
2631b16049aSZach Atkins        str: Diff output between result and expected CSVs
2641b16049aSZach Atkins    """
2651b16049aSZach Atkins    test_lines: list[str] = test_csv.read_text().splitlines()
2661b16049aSZach Atkins    true_lines: list[str] = true_csv.read_text().splitlines()
2671b16049aSZach Atkins
2681b16049aSZach Atkins    if test_lines[0] != true_lines[0]:
2691b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
2701b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
2711b16049aSZach Atkins
2721b16049aSZach Atkins    diff_lines: list[str] = list()
2731b16049aSZach Atkins    column_names: list[str] = true_lines[0].strip().split(',')
2741b16049aSZach Atkins    for test_line, true_line in zip(test_lines[1:], true_lines[1:]):
2751b16049aSZach Atkins        test_vals: list[float] = [float(val.strip()) for val in test_line.strip().split(',')]
2761b16049aSZach Atkins        true_vals: list[float] = [float(val.strip()) for val in true_line.strip().split(',')]
2771b16049aSZach Atkins        for test_val, true_val, column_name in zip(test_vals, true_vals, column_names):
2781b16049aSZach Atkins            true_zero: bool = abs(true_val) < zero_tol
2791b16049aSZach Atkins            test_zero: bool = abs(test_val) < zero_tol
2801b16049aSZach Atkins            fail: bool = False
2811b16049aSZach Atkins            if true_zero:
2821b16049aSZach Atkins                fail = not test_zero
2831b16049aSZach Atkins            else:
2841b16049aSZach Atkins                fail = not isclose(test_val, true_val, rel_tol=rel_tol)
2851b16049aSZach Atkins            if fail:
2861b16049aSZach Atkins                diff_lines.append(f'step: {true_line[0]}, column: {column_name}, expected: {true_val}, got: {test_val}')
2871b16049aSZach Atkins    return '\n'.join(diff_lines)
2881b16049aSZach Atkins
2891b16049aSZach Atkins
2901b16049aSZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, tolerance: float = 1e-12) -> str:
2911b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
2921b16049aSZach Atkins
2931b16049aSZach Atkins    Args:
2941b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
2951b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
2961b16049aSZach Atkins        tolerance (float, optional): Tolerance for comparing floating-point values
2971b16049aSZach Atkins
2981b16049aSZach Atkins    Returns:
2991b16049aSZach Atkins        str: Diff output between result and expected CGNS files
3001b16049aSZach Atkins    """
3011b16049aSZach Atkins    my_env: dict = os.environ.copy()
3021b16049aSZach Atkins
3031b16049aSZach Atkins    run_args: list[str] = ['cgnsdiff', '-d', '-t', f'{tolerance}', str(test_cgns), str(true_cgns)]
3041b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
3051b16049aSZach Atkins                          shell=True,
3061b16049aSZach Atkins                          stdout=subprocess.PIPE,
3071b16049aSZach Atkins                          stderr=subprocess.PIPE,
3081b16049aSZach Atkins                          env=my_env)
3091b16049aSZach Atkins
3101b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
3111b16049aSZach Atkins
3121b16049aSZach Atkins
3131b16049aSZach Atkinsdef run_tests(test: str, ceed_backends: list[str], mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestSuite:
3141b16049aSZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
3151b16049aSZach Atkins
3161b16049aSZach Atkins    Args:
3171b16049aSZach Atkins        test (str): Name of test
3181b16049aSZach Atkins        ceed_backends (list[str]): List of libCEED backends
3191b16049aSZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
3201b16049aSZach Atkins        nproc (int): Number of MPI processes to use when running each test case
3211b16049aSZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
3221b16049aSZach Atkins
3231b16049aSZach Atkins    Returns:
3241b16049aSZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
3251b16049aSZach Atkins    """
3261b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
3271b16049aSZach Atkins    test_specs: list[TestSpec] = get_test_args(source_path)
3281b16049aSZach Atkins
3291b16049aSZach Atkins    if mode is RunMode.TAP:
3301b16049aSZach Atkins        print('1..' + str(len(test_specs) * len(ceed_backends)))
3311b16049aSZach Atkins
3321b16049aSZach Atkins    test_cases: list[TestCase] = []
3331b16049aSZach Atkins    my_env: dict = os.environ.copy()
3341b16049aSZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
3351b16049aSZach Atkins
3361b16049aSZach Atkins    index: int = 1
3371b16049aSZach Atkins    for spec in test_specs:
3381b16049aSZach Atkins        for ceed_resource in ceed_backends:
3391b16049aSZach Atkins            run_args: list = [suite_spec.get_run_path(test), *spec.args]
3401b16049aSZach Atkins
3411b16049aSZach Atkins            if '{ceed_resource}' in run_args:
3421b16049aSZach Atkins                run_args[run_args.index('{ceed_resource}')] = ceed_resource
3431b16049aSZach Atkins            if '{nproc}' in run_args:
3441b16049aSZach Atkins                run_args[run_args.index('{nproc}')] = f'{nproc}'
3451b16049aSZach Atkins            elif nproc > 1 and source_path.suffix != '.py':
3461b16049aSZach Atkins                run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
3471b16049aSZach Atkins
3481b16049aSZach Atkins            # run test
3491b16049aSZach Atkins            skip_reason: str = suite_spec.check_pre_skip(test, spec, ceed_resource, nproc)
3501b16049aSZach Atkins            if skip_reason:
3511b16049aSZach Atkins                test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {ceed_resource}',
3521b16049aSZach Atkins                                               elapsed_sec=0,
3531b16049aSZach Atkins                                               timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
3541b16049aSZach Atkins                                               stdout='',
3551b16049aSZach Atkins                                               stderr='')
3561b16049aSZach Atkins                test_case.add_skipped_info(skip_reason)
3571b16049aSZach Atkins            else:
3581b16049aSZach Atkins                start: float = time.time()
3591b16049aSZach Atkins                proc = subprocess.run(' '.join(str(arg) for arg in run_args),
3601b16049aSZach Atkins                                      shell=True,
3611b16049aSZach Atkins                                      stdout=subprocess.PIPE,
3621b16049aSZach Atkins                                      stderr=subprocess.PIPE,
3631b16049aSZach Atkins                                      env=my_env)
3641b16049aSZach Atkins
3651b16049aSZach Atkins                test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {ceed_resource}',
3661b16049aSZach Atkins                                     classname=source_path.parent,
3671b16049aSZach Atkins                                     elapsed_sec=time.time() - start,
3681b16049aSZach Atkins                                     timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
3691b16049aSZach Atkins                                     stdout=proc.stdout.decode('utf-8'),
3701b16049aSZach Atkins                                     stderr=proc.stderr.decode('utf-8'),
3711b16049aSZach Atkins                                     allow_multiple_subelements=True)
3721b16049aSZach Atkins                ref_csvs: list[Path] = []
37397fab443SJeremy L Thompson                output_files: list[str] = [arg for arg in spec.args if 'ascii:' in arg]
37497fab443SJeremy L Thompson                if output_files:
3751b16049aSZach Atkins                    ref_csvs = [suite_spec.get_output_path(test, file.split('ascii:')[-1]) for file in output_files]
3761b16049aSZach Atkins                ref_cgns: list[Path] = []
37797fab443SJeremy L Thompson                output_files = [arg for arg in spec.args if 'cgns:' in arg]
37897fab443SJeremy L Thompson                if output_files:
3791b16049aSZach Atkins                    ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
3801b16049aSZach Atkins                ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
3811b16049aSZach Atkins                suite_spec.post_test_hook(test, spec)
3821b16049aSZach Atkins
3831b16049aSZach Atkins            # check allowed failures
3841b16049aSZach Atkins            if not test_case.is_skipped() and test_case.stderr:
3851b16049aSZach Atkins                skip_reason: str = suite_spec.check_post_skip(test, spec, ceed_resource, test_case.stderr)
3861b16049aSZach Atkins                if skip_reason:
3871b16049aSZach Atkins                    test_case.add_skipped_info(skip_reason)
3881b16049aSZach Atkins
3891b16049aSZach Atkins            # check required failures
3901b16049aSZach Atkins            if not test_case.is_skipped():
391*2fee3251SSebastian Grimberg                required_message, did_fail = suite_spec.check_required_failure(
392*2fee3251SSebastian Grimberg                    test, spec, ceed_resource, test_case.stderr)
3931b16049aSZach Atkins                if required_message and did_fail:
3941b16049aSZach Atkins                    test_case.status = f'fails with required: {required_message}'
3951b16049aSZach Atkins                elif required_message:
3961b16049aSZach Atkins                    test_case.add_failure_info(f'required failure missing: {required_message}')
3971b16049aSZach Atkins
3981b16049aSZach Atkins            # classify other results
3991b16049aSZach Atkins            if not test_case.is_skipped() and not test_case.status:
4001b16049aSZach Atkins                if test_case.stderr:
4011b16049aSZach Atkins                    test_case.add_failure_info('stderr', test_case.stderr)
4021b16049aSZach Atkins                if proc.returncode != 0:
4031b16049aSZach Atkins                    test_case.add_error_info(f'returncode = {proc.returncode}')
4041b16049aSZach Atkins                if ref_stdout.is_file():
4051b16049aSZach Atkins                    diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
4061b16049aSZach Atkins                                                     test_case.stdout.splitlines(keepends=True),
4071b16049aSZach Atkins                                                     fromfile=str(ref_stdout),
4081b16049aSZach Atkins                                                     tofile='New'))
4091b16049aSZach Atkins                    if diff:
4101b16049aSZach Atkins                        test_case.add_failure_info('stdout', output=''.join(diff))
4111b16049aSZach Atkins                elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
4121b16049aSZach Atkins                    test_case.add_failure_info('stdout', output=test_case.stdout)
4131b16049aSZach Atkins                # expected CSV output
4141b16049aSZach Atkins                for ref_csv in ref_csvs:
4151b16049aSZach Atkins                    if not ref_csv.is_file():
4161b16049aSZach Atkins                        test_case.add_failure_info('csv', output=f'{ref_csv} not found')
4171b16049aSZach Atkins                    else:
4181b16049aSZach Atkins                        diff: str = diff_csv(Path.cwd() / ref_csv.name, ref_csv)
4191b16049aSZach Atkins                        if diff:
4201b16049aSZach Atkins                            test_case.add_failure_info('csv', output=diff)
4211b16049aSZach Atkins                        else:
4221b16049aSZach Atkins                            (Path.cwd() / ref_csv.name).unlink()
4231b16049aSZach Atkins                # expected CGNS output
4241b16049aSZach Atkins                for ref_cgn in ref_cgns:
4251b16049aSZach Atkins                    if not ref_cgn.is_file():
4261b16049aSZach Atkins                        test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
4271b16049aSZach Atkins                    else:
4281b16049aSZach Atkins                        diff = diff_cgns(Path.cwd() / ref_cgn.name, ref_cgn)
4291b16049aSZach Atkins                        if diff:
4301b16049aSZach Atkins                            test_case.add_failure_info('cgns', output=diff)
4311b16049aSZach Atkins                        else:
4321b16049aSZach Atkins                            (Path.cwd() / ref_cgn.name).unlink()
4331b16049aSZach Atkins
4341b16049aSZach Atkins            # store result
4351b16049aSZach Atkins            test_case.args = ' '.join(str(arg) for arg in run_args)
4361b16049aSZach Atkins            test_cases.append(test_case)
4371b16049aSZach Atkins
4381b16049aSZach Atkins            if mode is RunMode.TAP:
4391b16049aSZach Atkins                # print incremental output if TAP mode
4401b16049aSZach Atkins                print(f'# Test: {spec.name}')
4411b16049aSZach Atkins                if spec.only:
4421b16049aSZach Atkins                    print('# Only: {}'.format(','.join(spec.only)))
4431b16049aSZach Atkins                print(f'# $ {test_case.args}')
4441b16049aSZach Atkins                if test_case.is_skipped():
4451b16049aSZach Atkins                    print('ok {} - SKIP: {}'.format(index, (test_case.skipped[0]['message'] or 'NO MESSAGE').strip()))
4461b16049aSZach Atkins                elif test_case.is_failure() or test_case.is_error():
4471b16049aSZach Atkins                    print(f'not ok {index}')
4481b16049aSZach Atkins                    if test_case.is_error():
4491b16049aSZach Atkins                        print(f'  ERROR: {test_case.errors[0]["message"]}')
4501b16049aSZach Atkins                    if test_case.is_failure():
4511b16049aSZach Atkins                        for i, failure in enumerate(test_case.failures):
4521b16049aSZach Atkins                            print(f'  FAILURE {i}: {failure["message"]}')
4531b16049aSZach Atkins                            print(f'    Output: \n{failure["output"]}')
4541b16049aSZach Atkins                else:
4551b16049aSZach Atkins                    print(f'ok {index} - PASS')
4561b16049aSZach Atkins                sys.stdout.flush()
4571b16049aSZach Atkins            else:
4581b16049aSZach Atkins                # print error or failure information if JUNIT mode
4591b16049aSZach Atkins                if test_case.is_error() or test_case.is_failure():
4601b16049aSZach Atkins                    print(f'Test: {test} {spec.name}')
4611b16049aSZach Atkins                    print(f'  $ {test_case.args}')
4621b16049aSZach Atkins                    if test_case.is_error():
4631b16049aSZach Atkins                        print('ERROR: {}'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()))
4641b16049aSZach Atkins                        print('Output: \n{}'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()))
4651b16049aSZach Atkins                    if test_case.is_failure():
4661b16049aSZach Atkins                        for failure in test_case.failures:
4671b16049aSZach Atkins                            print('FAIL: {}'.format((failure['message'] or 'NO MESSAGE').strip()))
4681b16049aSZach Atkins                            print('Output: \n{}'.format((failure['output'] or 'NO MESSAGE').strip()))
4691b16049aSZach Atkins                sys.stdout.flush()
4701b16049aSZach Atkins            index += 1
4711b16049aSZach Atkins
4721b16049aSZach Atkins    return TestSuite(test, test_cases)
4731b16049aSZach Atkins
4741b16049aSZach Atkins
4751b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
4761b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
4771b16049aSZach Atkins
4781b16049aSZach Atkins    Args:
4791b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
4801b16049aSZach Atkins        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
4811b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
4821b16049aSZach Atkins    """
4831b16049aSZach Atkins    output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
4841b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
4851b16049aSZach Atkins
4861b16049aSZach Atkins
4871b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
4881b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
4891b16049aSZach Atkins
4901b16049aSZach Atkins    Args:
4911b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
4921b16049aSZach Atkins
4931b16049aSZach Atkins    Returns:
4941b16049aSZach Atkins        bool: True if any test cases failed
4951b16049aSZach Atkins    """
4961b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
497