xref: /libCEED/tests/junit_common.py (revision 8c81f8b02f3c08cdd9dd48147f6804f3e275d73f)
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
1119868e18SZach Atkinsimport multiprocessing as mp
1219868e18SZach Atkinsfrom itertools import product
131b16049aSZach Atkinsimport sys
141b16049aSZach Atkinsimport time
1578cb100bSJames Wrightfrom typing import Optional, Tuple, List
161b16049aSZach Atkins
171b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
181b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
191b16049aSZach Atkins
201b16049aSZach Atkins
211b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action):
221b16049aSZach Atkins    """Action to convert input values to lower case prior to converting to an Enum type"""
231b16049aSZach Atkins
241b16049aSZach Atkins    def __init__(self, option_strings, dest, type, default, **kwargs):
251b16049aSZach Atkins        if not (issubclass(type, Enum) and issubclass(type, str)):
261b16049aSZach Atkins            raise ValueError(f"{type} must be a StrEnum or str and Enum")
271b16049aSZach Atkins        # store provided enum type
281b16049aSZach Atkins        self.enum_type = type
291b16049aSZach Atkins        if isinstance(default, str):
301b16049aSZach Atkins            default = self.enum_type(default.lower())
311b16049aSZach Atkins        else:
321b16049aSZach Atkins            default = [self.enum_type(v.lower()) for v in default]
331b16049aSZach Atkins        # prevent automatic type conversion
341b16049aSZach Atkins        super().__init__(option_strings, dest, default=default, **kwargs)
351b16049aSZach Atkins
361b16049aSZach Atkins    def __call__(self, parser, namespace, values, option_string=None):
371b16049aSZach Atkins        if isinstance(values, str):
381b16049aSZach Atkins            values = self.enum_type(values.lower())
391b16049aSZach Atkins        else:
401b16049aSZach Atkins            values = [self.enum_type(v.lower()) for v in values]
411b16049aSZach Atkins        setattr(namespace, self.dest, values)
421b16049aSZach Atkins
431b16049aSZach Atkins
441b16049aSZach Atkins@dataclass
451b16049aSZach Atkinsclass TestSpec:
461b16049aSZach Atkins    """Dataclass storing information about a single test case"""
471b16049aSZach Atkins    name: str
488938a869SZach Atkins    only: List = field(default_factory=list)
498938a869SZach Atkins    args: List = field(default_factory=list)
501b16049aSZach Atkins
511b16049aSZach Atkins
521b16049aSZach Atkinsclass RunMode(str, Enum):
531b16049aSZach Atkins    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
541b16049aSZach Atkins    __str__ = str.__str__
551b16049aSZach Atkins    __format__ = str.__format__
561b16049aSZach Atkins    TAP: str = 'tap'
571b16049aSZach Atkins    JUNIT: str = 'junit'
581b16049aSZach Atkins
591b16049aSZach Atkins
601b16049aSZach Atkinsclass SuiteSpec(ABC):
611b16049aSZach Atkins    """Abstract Base Class defining the required interface for running a test suite"""
621b16049aSZach Atkins    @abstractmethod
631b16049aSZach Atkins    def get_source_path(self, test: str) -> Path:
641b16049aSZach Atkins        """Compute path to test source file
651b16049aSZach Atkins
661b16049aSZach Atkins        Args:
671b16049aSZach Atkins            test (str): Name of test
681b16049aSZach Atkins
691b16049aSZach Atkins        Returns:
701b16049aSZach Atkins            Path: Path to source file
711b16049aSZach Atkins        """
721b16049aSZach Atkins        raise NotImplementedError
731b16049aSZach Atkins
741b16049aSZach Atkins    @abstractmethod
751b16049aSZach Atkins    def get_run_path(self, test: str) -> Path:
761b16049aSZach Atkins        """Compute path to built test executable file
771b16049aSZach Atkins
781b16049aSZach Atkins        Args:
791b16049aSZach Atkins            test (str): Name of test
801b16049aSZach Atkins
811b16049aSZach Atkins        Returns:
821b16049aSZach Atkins            Path: Path to test executable
831b16049aSZach Atkins        """
841b16049aSZach Atkins        raise NotImplementedError
851b16049aSZach Atkins
861b16049aSZach Atkins    @abstractmethod
871b16049aSZach Atkins    def get_output_path(self, test: str, output_file: str) -> Path:
881b16049aSZach Atkins        """Compute path to expected output file
891b16049aSZach Atkins
901b16049aSZach Atkins        Args:
911b16049aSZach Atkins            test (str): Name of test
921b16049aSZach Atkins            output_file (str): File name of output file
931b16049aSZach Atkins
941b16049aSZach Atkins        Returns:
951b16049aSZach Atkins            Path: Path to expected output file
961b16049aSZach Atkins        """
971b16049aSZach Atkins        raise NotImplementedError
981b16049aSZach Atkins
991b16049aSZach Atkins    def post_test_hook(self, test: str, spec: TestSpec) -> None:
1001b16049aSZach Atkins        """Function callback ran after each test case
1011b16049aSZach Atkins
1021b16049aSZach Atkins        Args:
1031b16049aSZach Atkins            test (str): Name of test
1041b16049aSZach Atkins            spec (TestSpec): Test case specification
1051b16049aSZach Atkins        """
1061b16049aSZach Atkins        pass
1071b16049aSZach Atkins
1081b16049aSZach Atkins    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1091b16049aSZach Atkins        """Check if a test case should be skipped prior to running, returning the reason for skipping
1101b16049aSZach Atkins
1111b16049aSZach Atkins        Args:
1121b16049aSZach Atkins            test (str): Name of test
1131b16049aSZach Atkins            spec (TestSpec): Test case specification
1141b16049aSZach Atkins            resource (str): libCEED backend
1151b16049aSZach Atkins            nproc (int): Number of MPI processes to use when running test case
1161b16049aSZach Atkins
1171b16049aSZach Atkins        Returns:
1181b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if test case should not be skipped
1191b16049aSZach Atkins        """
1201b16049aSZach Atkins        return None
1211b16049aSZach Atkins
1221b16049aSZach Atkins    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1231b16049aSZach Atkins        """Check if a test case should be allowed to fail, based on its stderr output
1241b16049aSZach Atkins
1251b16049aSZach Atkins        Args:
1261b16049aSZach Atkins            test (str): Name of test
1271b16049aSZach Atkins            spec (TestSpec): Test case specification
1281b16049aSZach Atkins            resource (str): libCEED backend
1291b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1301b16049aSZach Atkins
1311b16049aSZach Atkins        Returns:
13219868e18SZach Atkins            Optional[str]: Skip reason, or `None` if unexpected error
1331b16049aSZach Atkins        """
1341b16049aSZach Atkins        return None
1351b16049aSZach Atkins
13678cb100bSJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
1371b16049aSZach Atkins        """Check whether a test case is expected to fail and if it failed expectedly
1381b16049aSZach Atkins
1391b16049aSZach Atkins        Args:
1401b16049aSZach Atkins            test (str): Name of test
1411b16049aSZach Atkins            spec (TestSpec): Test case specification
1421b16049aSZach Atkins            resource (str): libCEED backend
1431b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1441b16049aSZach Atkins
1451b16049aSZach Atkins        Returns:
1461b16049aSZach Atkins            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
1471b16049aSZach Atkins        """
1481b16049aSZach Atkins        return '', True
1491b16049aSZach Atkins
1501b16049aSZach Atkins    def check_allowed_stdout(self, test: str) -> bool:
1511b16049aSZach Atkins        """Check whether a test is allowed to print console output
1521b16049aSZach Atkins
1531b16049aSZach Atkins        Args:
1541b16049aSZach Atkins            test (str): Name of test
1551b16049aSZach Atkins
1561b16049aSZach Atkins        Returns:
1571b16049aSZach Atkins            bool: True if the test is allowed to print console output
1581b16049aSZach Atkins        """
1591b16049aSZach Atkins        return False
1601b16049aSZach Atkins
1611b16049aSZach Atkins
1621b16049aSZach Atkinsdef has_cgnsdiff() -> bool:
1631b16049aSZach Atkins    """Check whether `cgnsdiff` is an executable program in the current environment
1641b16049aSZach Atkins
1651b16049aSZach Atkins    Returns:
1661b16049aSZach Atkins        bool: True if `cgnsdiff` is found
1671b16049aSZach Atkins    """
1681b16049aSZach Atkins    my_env: dict = os.environ.copy()
1691b16049aSZach Atkins    proc = subprocess.run('cgnsdiff',
1701b16049aSZach Atkins                          shell=True,
1711b16049aSZach Atkins                          stdout=subprocess.PIPE,
1721b16049aSZach Atkins                          stderr=subprocess.PIPE,
1731b16049aSZach Atkins                          env=my_env)
1741b16049aSZach Atkins    return 'not found' not in proc.stderr.decode('utf-8')
1751b16049aSZach Atkins
1761b16049aSZach Atkins
17778cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
1781b16049aSZach Atkins    """Helper function, checks if any of the substrings are included in the base string
1791b16049aSZach Atkins
1801b16049aSZach Atkins    Args:
1811b16049aSZach Atkins        base (str): Base string to search in
1828938a869SZach Atkins        substrings (List[str]): List of potential substrings
1831b16049aSZach Atkins
1841b16049aSZach Atkins    Returns:
1851b16049aSZach Atkins        bool: True if any substrings are included in base string
1861b16049aSZach Atkins    """
1871b16049aSZach Atkins    return any((sub in base for sub in substrings))
1881b16049aSZach Atkins
1891b16049aSZach Atkins
19078cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
1911b16049aSZach Atkins    """Helper function, checks if the base string is prefixed by any of `prefixes`
1921b16049aSZach Atkins
1931b16049aSZach Atkins    Args:
1941b16049aSZach Atkins        base (str): Base string to search
1958938a869SZach Atkins        prefixes (List[str]): List of potential prefixes
1961b16049aSZach Atkins
1971b16049aSZach Atkins    Returns:
1981b16049aSZach Atkins        bool: True if base string is prefixed by any of the prefixes
1991b16049aSZach Atkins    """
2001b16049aSZach Atkins    return any((base.startswith(prefix) for prefix in prefixes))
2011b16049aSZach Atkins
2021b16049aSZach Atkins
2031b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec:
2041b16049aSZach Atkins    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
2051b16049aSZach Atkins
2061b16049aSZach Atkins    Args:
2071b16049aSZach Atkins        line (str): String containing TESTARGS specification and CLI arguments
2081b16049aSZach Atkins
2091b16049aSZach Atkins    Returns:
2101b16049aSZach Atkins        TestSpec: Parsed specification of test case
2111b16049aSZach Atkins    """
21278cb100bSJames Wright    args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip())
2131b16049aSZach Atkins    if args[0] == 'TESTARGS':
2141b16049aSZach Atkins        return TestSpec(name='', args=args[1:])
2151b16049aSZach Atkins    raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')]
2161b16049aSZach Atkins    # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'}
2171b16049aSZach Atkins    test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)])
218f85e4a7bSJeremy L Thompson    name: str = test_args.get('name', '')
21978cb100bSJames Wright    constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else []
2201b16049aSZach Atkins    if len(args) > 1:
221f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints, args=args[1:])
2221b16049aSZach Atkins    else:
223f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints)
2241b16049aSZach Atkins
2251b16049aSZach Atkins
22678cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
2271b16049aSZach Atkins    """Parse all test cases from a given source file
2281b16049aSZach Atkins
2291b16049aSZach Atkins    Args:
2301b16049aSZach Atkins        source_file (Path): Path to source file
2311b16049aSZach Atkins
2321b16049aSZach Atkins    Raises:
2331b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
2341b16049aSZach Atkins
2351b16049aSZach Atkins    Returns:
2368938a869SZach Atkins        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
2371b16049aSZach Atkins    """
2381b16049aSZach Atkins    comment_str: str = ''
239*8c81f8b0SPeter Munch    if source_file.suffix in ['.c', '.cc', '.cpp']:
2401b16049aSZach Atkins        comment_str = '//'
2411b16049aSZach Atkins    elif source_file.suffix in ['.py']:
2421b16049aSZach Atkins        comment_str = '#'
2431b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
2441b16049aSZach Atkins        comment_str = 'C_'
2451b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
2461b16049aSZach Atkins        comment_str = '! '
2471b16049aSZach Atkins    else:
2481b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
2491b16049aSZach Atkins
2501b16049aSZach Atkins    return [parse_test_line(line.strip(comment_str))
2511b16049aSZach Atkins            for line in source_file.read_text().splitlines()
2521b16049aSZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
2531b16049aSZach Atkins
2541b16049aSZach Atkins
2551b16049aSZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2) -> str:
2561b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
2571b16049aSZach Atkins
2581b16049aSZach Atkins    Args:
2591b16049aSZach Atkins        test_csv (Path): Path to output CSV results
2601b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
2611b16049aSZach Atkins        zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10.
2621b16049aSZach Atkins        rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2.
2631b16049aSZach Atkins
2641b16049aSZach Atkins    Returns:
2651b16049aSZach Atkins        str: Diff output between result and expected CSVs
2661b16049aSZach Atkins    """
26778cb100bSJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
26878cb100bSJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
2691b16049aSZach Atkins
2701b16049aSZach Atkins    if test_lines[0] != true_lines[0]:
2711b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
2721b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
2731b16049aSZach Atkins
27478cb100bSJames Wright    diff_lines: List[str] = list()
27578cb100bSJames Wright    column_names: List[str] = true_lines[0].strip().split(',')
2761b16049aSZach Atkins    for test_line, true_line in zip(test_lines[1:], true_lines[1:]):
27778cb100bSJames Wright        test_vals: List[float] = [float(val.strip()) for val in test_line.strip().split(',')]
27878cb100bSJames Wright        true_vals: List[float] = [float(val.strip()) for val in true_line.strip().split(',')]
2791b16049aSZach Atkins        for test_val, true_val, column_name in zip(test_vals, true_vals, column_names):
2801b16049aSZach Atkins            true_zero: bool = abs(true_val) < zero_tol
2811b16049aSZach Atkins            test_zero: bool = abs(test_val) < zero_tol
2821b16049aSZach Atkins            fail: bool = False
2831b16049aSZach Atkins            if true_zero:
2841b16049aSZach Atkins                fail = not test_zero
2851b16049aSZach Atkins            else:
2861b16049aSZach Atkins                fail = not isclose(test_val, true_val, rel_tol=rel_tol)
2871b16049aSZach Atkins            if fail:
2881b16049aSZach Atkins                diff_lines.append(f'step: {true_line[0]}, column: {column_name}, expected: {true_val}, got: {test_val}')
2891b16049aSZach Atkins    return '\n'.join(diff_lines)
2901b16049aSZach Atkins
2911b16049aSZach Atkins
2921b16049aSZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, tolerance: float = 1e-12) -> str:
2931b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
2941b16049aSZach Atkins
2951b16049aSZach Atkins    Args:
2961b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
2971b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
2981b16049aSZach Atkins        tolerance (float, optional): Tolerance for comparing floating-point values
2991b16049aSZach Atkins
3001b16049aSZach Atkins    Returns:
3011b16049aSZach Atkins        str: Diff output between result and expected CGNS files
3021b16049aSZach Atkins    """
3031b16049aSZach Atkins    my_env: dict = os.environ.copy()
3041b16049aSZach Atkins
30578cb100bSJames Wright    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{tolerance}', str(test_cgns), str(true_cgns)]
3061b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
3071b16049aSZach Atkins                          shell=True,
3081b16049aSZach Atkins                          stdout=subprocess.PIPE,
3091b16049aSZach Atkins                          stderr=subprocess.PIPE,
3101b16049aSZach Atkins                          env=my_env)
3111b16049aSZach Atkins
3121b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
3131b16049aSZach Atkins
3141b16049aSZach Atkins
315e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
316e17e35bbSJames Wright                            backend: str, test: str, index: int) -> str:
317e17e35bbSJames Wright    output_str = ''
318e17e35bbSJames Wright    if mode is RunMode.TAP:
319e17e35bbSJames Wright        # print incremental output if TAP mode
320e17e35bbSJames Wright        if test_case.is_skipped():
321e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
322e17e35bbSJames Wright        elif test_case.is_failure() or test_case.is_error():
323e17e35bbSJames Wright            output_str += f'    not ok {index} - {spec.name}, {backend}\n'
324e17e35bbSJames Wright        else:
325e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend}\n'
326e17e35bbSJames Wright        output_str += f'      ---\n'
327e17e35bbSJames Wright        if spec.only:
328e17e35bbSJames Wright            output_str += f'      only: {",".join(spec.only)}\n'
329e17e35bbSJames Wright        output_str += f'      args: {test_case.args}\n'
330e17e35bbSJames Wright        if test_case.is_error():
331e17e35bbSJames Wright            output_str += f'      error: {test_case.errors[0]["message"]}\n'
332e17e35bbSJames Wright        if test_case.is_failure():
333e17e35bbSJames Wright            output_str += f'      num_failures: {len(test_case.failures)}\n'
334e17e35bbSJames Wright            for i, failure in enumerate(test_case.failures):
335e17e35bbSJames Wright                output_str += f'      failure_{i}: {failure["message"]}\n'
336e17e35bbSJames Wright                output_str += f'        message: {failure["message"]}\n'
337e17e35bbSJames Wright                if failure["output"]:
338e17e35bbSJames Wright                    out = failure["output"].strip().replace('\n', '\n          ')
339e17e35bbSJames Wright                    output_str += f'        output: |\n          {out}\n'
340e17e35bbSJames Wright        output_str += f'      ...\n'
341e17e35bbSJames Wright    else:
342e17e35bbSJames Wright        # print error or failure information if JUNIT mode
343e17e35bbSJames Wright        if test_case.is_error() or test_case.is_failure():
344e17e35bbSJames Wright            output_str += f'Test: {test} {spec.name}\n'
345e17e35bbSJames Wright            output_str += f'  $ {test_case.args}\n'
346e17e35bbSJames Wright            if test_case.is_error():
347e17e35bbSJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
348e17e35bbSJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
349e17e35bbSJames Wright            if test_case.is_failure():
350e17e35bbSJames Wright                for failure in test_case.failures:
351e17e35bbSJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
352e17e35bbSJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
353e17e35bbSJames Wright    return output_str
354e17e35bbSJames Wright
355e17e35bbSJames Wright
35619868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str,
35719868e18SZach Atkins             mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase:
35819868e18SZach Atkins    """Run a single test case and backend combination
3591b16049aSZach Atkins
3601b16049aSZach Atkins    Args:
3618938a869SZach Atkins        index (int): Index of backend for current spec
36219868e18SZach Atkins        test (str): Path to test
36319868e18SZach Atkins        spec (TestSpec): Specification of test case
36419868e18SZach Atkins        backend (str): CEED backend
36519868e18SZach Atkins        mode (RunMode): Output mode
36619868e18SZach Atkins        nproc (int): Number of MPI processes to use when running test case
36719868e18SZach Atkins        suite_spec (SuiteSpec): Specification of test suite
3681b16049aSZach Atkins
3691b16049aSZach Atkins    Returns:
37019868e18SZach Atkins        TestCase: Test case result
3711b16049aSZach Atkins    """
3721b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
3738938a869SZach Atkins    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
3741b16049aSZach Atkins
3751b16049aSZach Atkins    if '{ceed_resource}' in run_args:
37619868e18SZach Atkins        run_args[run_args.index('{ceed_resource}')] = backend
3778938a869SZach Atkins    for i, arg in enumerate(run_args):
3788938a869SZach Atkins        if '{ceed_resource}' in arg:
3798938a869SZach Atkins            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
3801b16049aSZach Atkins    if '{nproc}' in run_args:
3811b16049aSZach Atkins        run_args[run_args.index('{nproc}')] = f'{nproc}'
3821b16049aSZach Atkins    elif nproc > 1 and source_path.suffix != '.py':
3831b16049aSZach Atkins        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
3841b16049aSZach Atkins
3851b16049aSZach Atkins    # run test
38619868e18SZach Atkins    skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc)
3871b16049aSZach Atkins    if skip_reason:
38819868e18SZach Atkins        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
3891b16049aSZach Atkins                                       elapsed_sec=0,
3901b16049aSZach Atkins                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
3911b16049aSZach Atkins                                       stdout='',
3928938a869SZach Atkins                                       stderr='',
3938938a869SZach Atkins                                       category=spec.name,)
3941b16049aSZach Atkins        test_case.add_skipped_info(skip_reason)
3951b16049aSZach Atkins    else:
3961b16049aSZach Atkins        start: float = time.time()
3971b16049aSZach Atkins        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
3981b16049aSZach Atkins                              shell=True,
3991b16049aSZach Atkins                              stdout=subprocess.PIPE,
4001b16049aSZach Atkins                              stderr=subprocess.PIPE,
4011b16049aSZach Atkins                              env=my_env)
4021b16049aSZach Atkins
40319868e18SZach Atkins        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
4041b16049aSZach Atkins                             classname=source_path.parent,
4051b16049aSZach Atkins                             elapsed_sec=time.time() - start,
4061b16049aSZach Atkins                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
4071b16049aSZach Atkins                             stdout=proc.stdout.decode('utf-8'),
4081b16049aSZach Atkins                             stderr=proc.stderr.decode('utf-8'),
4098938a869SZach Atkins                             allow_multiple_subelements=True,
4108938a869SZach Atkins                             category=spec.name,)
41178cb100bSJames Wright        ref_csvs: List[Path] = []
4128938a869SZach Atkins        output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg]
41397fab443SJeremy L Thompson        if output_files:
4141b16049aSZach Atkins            ref_csvs = [suite_spec.get_output_path(test, file.split('ascii:')[-1]) for file in output_files]
41578cb100bSJames Wright        ref_cgns: List[Path] = []
4168938a869SZach Atkins        output_files = [arg for arg in run_args if 'cgns:' in arg]
41797fab443SJeremy L Thompson        if output_files:
4181b16049aSZach Atkins            ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
4191b16049aSZach Atkins        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
4201b16049aSZach Atkins        suite_spec.post_test_hook(test, spec)
4211b16049aSZach Atkins
4221b16049aSZach Atkins    # check allowed failures
4231b16049aSZach Atkins    if not test_case.is_skipped() and test_case.stderr:
42419868e18SZach Atkins        skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
4251b16049aSZach Atkins        if skip_reason:
4261b16049aSZach Atkins            test_case.add_skipped_info(skip_reason)
4271b16049aSZach Atkins
4281b16049aSZach Atkins    # check required failures
4291b16049aSZach Atkins    if not test_case.is_skipped():
4302fee3251SSebastian Grimberg        required_message, did_fail = suite_spec.check_required_failure(
43119868e18SZach Atkins            test, spec, backend, test_case.stderr)
4321b16049aSZach Atkins        if required_message and did_fail:
4331b16049aSZach Atkins            test_case.status = f'fails with required: {required_message}'
4341b16049aSZach Atkins        elif required_message:
4351b16049aSZach Atkins            test_case.add_failure_info(f'required failure missing: {required_message}')
4361b16049aSZach Atkins
4371b16049aSZach Atkins    # classify other results
4381b16049aSZach Atkins    if not test_case.is_skipped() and not test_case.status:
4391b16049aSZach Atkins        if test_case.stderr:
4401b16049aSZach Atkins            test_case.add_failure_info('stderr', test_case.stderr)
4411b16049aSZach Atkins        if proc.returncode != 0:
4421b16049aSZach Atkins            test_case.add_error_info(f'returncode = {proc.returncode}')
4431b16049aSZach Atkins        if ref_stdout.is_file():
4441b16049aSZach Atkins            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
4451b16049aSZach Atkins                                             test_case.stdout.splitlines(keepends=True),
4461b16049aSZach Atkins                                             fromfile=str(ref_stdout),
4471b16049aSZach Atkins                                             tofile='New'))
4481b16049aSZach Atkins            if diff:
4491b16049aSZach Atkins                test_case.add_failure_info('stdout', output=''.join(diff))
4501b16049aSZach Atkins        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
4511b16049aSZach Atkins            test_case.add_failure_info('stdout', output=test_case.stdout)
4521b16049aSZach Atkins        # expected CSV output
4531b16049aSZach Atkins        for ref_csv in ref_csvs:
4548938a869SZach Atkins            csv_name = ref_csv.name
4558938a869SZach Atkins            if not ref_csv.is_file():
4568938a869SZach Atkins                # remove _{ceed_backend} from path name
4578938a869SZach Atkins                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
4581b16049aSZach Atkins            if not ref_csv.is_file():
4591b16049aSZach Atkins                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
4601b16049aSZach Atkins            else:
4618938a869SZach Atkins                diff: str = diff_csv(Path.cwd() / csv_name, ref_csv)
4621b16049aSZach Atkins                if diff:
4631b16049aSZach Atkins                    test_case.add_failure_info('csv', output=diff)
4641b16049aSZach Atkins                else:
4658938a869SZach Atkins                    (Path.cwd() / csv_name).unlink()
4661b16049aSZach Atkins        # expected CGNS output
4671b16049aSZach Atkins        for ref_cgn in ref_cgns:
4688938a869SZach Atkins            cgn_name = ref_cgn.name
4698938a869SZach Atkins            if not ref_cgn.is_file():
4708938a869SZach Atkins                # remove _{ceed_backend} from path name
4718938a869SZach Atkins                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
4721b16049aSZach Atkins            if not ref_cgn.is_file():
4731b16049aSZach Atkins                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
4741b16049aSZach Atkins            else:
4758938a869SZach Atkins                diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn)
4761b16049aSZach Atkins                if diff:
4771b16049aSZach Atkins                    test_case.add_failure_info('cgns', output=diff)
4781b16049aSZach Atkins                else:
4798938a869SZach Atkins                    (Path.cwd() / cgn_name).unlink()
4801b16049aSZach Atkins
4811b16049aSZach Atkins    # store result
4821b16049aSZach Atkins    test_case.args = ' '.join(str(arg) for arg in run_args)
483e17e35bbSJames Wright    output_str = test_case_output_string(test_case, spec, mode, backend, test, index)
48419868e18SZach Atkins
48519868e18SZach Atkins    return test_case, output_str
48619868e18SZach Atkins
48719868e18SZach Atkins
48819868e18SZach Atkinsdef init_process():
48919868e18SZach Atkins    """Initialize multiprocessing process"""
49019868e18SZach Atkins    # set up error handler
49119868e18SZach Atkins    global my_env
49219868e18SZach Atkins    my_env = os.environ.copy()
49319868e18SZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
49419868e18SZach Atkins
49519868e18SZach Atkins
49678cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
49719868e18SZach Atkins              suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite:
49819868e18SZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
49919868e18SZach Atkins
50019868e18SZach Atkins    Args:
50119868e18SZach Atkins        test (str): Name of test
5028938a869SZach Atkins        ceed_backends (List[str]): List of libCEED backends
50319868e18SZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
50419868e18SZach Atkins        nproc (int): Number of MPI processes to use when running each test case
50519868e18SZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
50619868e18SZach Atkins        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
50719868e18SZach Atkins
50819868e18SZach Atkins    Returns:
50919868e18SZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
51019868e18SZach Atkins    """
51178cb100bSJames Wright    test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test))
51219868e18SZach Atkins    if mode is RunMode.TAP:
5138938a869SZach Atkins        print('TAP version 13')
5148938a869SZach Atkins        print(f'1..{len(test_specs)}')
51519868e18SZach Atkins
51619868e18SZach Atkins    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
5178938a869SZach Atkins        async_outputs: List[List[mp.AsyncResult]] = [
5188938a869SZach Atkins            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec))
5198938a869SZach Atkins             for (i, backend) in enumerate(ceed_backends, start=1)]
5208938a869SZach Atkins            for spec in test_specs
5218938a869SZach Atkins        ]
52219868e18SZach Atkins
52319868e18SZach Atkins        test_cases = []
5248938a869SZach Atkins        for (i, subtest) in enumerate(async_outputs, start=1):
5258938a869SZach Atkins            is_new_subtest = True
5268938a869SZach Atkins            subtest_ok = True
5278938a869SZach Atkins            for async_output in subtest:
52819868e18SZach Atkins                test_case, print_output = async_output.get()
52919868e18SZach Atkins                test_cases.append(test_case)
5308938a869SZach Atkins                if is_new_subtest and mode == RunMode.TAP:
5318938a869SZach Atkins                    is_new_subtest = False
5328938a869SZach Atkins                    print(f'# Subtest: {test_case.category}')
5338938a869SZach Atkins                    print(f'    1..{len(ceed_backends)}')
53419868e18SZach Atkins                print(print_output, end='')
5358938a869SZach Atkins                if test_case.is_failure() or test_case.is_error():
5368938a869SZach Atkins                    subtest_ok = False
5378938a869SZach Atkins            if mode == RunMode.TAP:
5388938a869SZach Atkins                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
5391b16049aSZach Atkins
5401b16049aSZach Atkins    return TestSuite(test, test_cases)
5411b16049aSZach Atkins
5421b16049aSZach Atkins
5431b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
5441b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
5451b16049aSZach Atkins
5461b16049aSZach Atkins    Args:
5471b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
5481b16049aSZach Atkins        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
5491b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
5501b16049aSZach Atkins    """
5511b16049aSZach Atkins    output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
5521b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
5531b16049aSZach Atkins
5541b16049aSZach Atkins
5551b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
5561b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
5571b16049aSZach Atkins
5581b16049aSZach Atkins    Args:
5591b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
5601b16049aSZach Atkins
5611b16049aSZach Atkins    Returns:
5621b16049aSZach Atkins        bool: True if any test cases failed
5631b16049aSZach Atkins    """
5641b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
565