xref: /libCEED/tests/junit_common.py (revision 12235d7fd7d08f5cebdb10bab90c33bada4bdd05)
11b16049aSZach Atkinsfrom abc import ABC, abstractmethod
21b16049aSZach Atkinsimport argparse
369ef23b6SZach Atkinsimport csv
41b16049aSZach Atkinsfrom dataclasses import dataclass, field
51b16049aSZach Atkinsimport difflib
61b16049aSZach Atkinsfrom enum import Enum
71b16049aSZach Atkinsfrom math import isclose
81b16049aSZach Atkinsimport os
91b16049aSZach Atkinsfrom pathlib import Path
101b16049aSZach Atkinsimport re
111b16049aSZach Atkinsimport subprocess
1219868e18SZach Atkinsimport multiprocessing as mp
1319868e18SZach Atkinsfrom itertools import product
141b16049aSZach Atkinsimport sys
151b16049aSZach Atkinsimport time
16*12235d7fSJames Wrightfrom typing import Optional, Tuple, List, Callable
171b16049aSZach Atkins
181b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
191b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
201b16049aSZach Atkins
211b16049aSZach Atkins
221b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action):
231b16049aSZach Atkins    """Action to convert input values to lower case prior to converting to an Enum type"""
241b16049aSZach Atkins
251b16049aSZach Atkins    def __init__(self, option_strings, dest, type, default, **kwargs):
261b16049aSZach Atkins        if not (issubclass(type, Enum) and issubclass(type, str)):
271b16049aSZach Atkins            raise ValueError(f"{type} must be a StrEnum or str and Enum")
281b16049aSZach Atkins        # store provided enum type
291b16049aSZach Atkins        self.enum_type = type
301b16049aSZach Atkins        if isinstance(default, str):
311b16049aSZach Atkins            default = self.enum_type(default.lower())
321b16049aSZach Atkins        else:
331b16049aSZach Atkins            default = [self.enum_type(v.lower()) for v in default]
341b16049aSZach Atkins        # prevent automatic type conversion
351b16049aSZach Atkins        super().__init__(option_strings, dest, default=default, **kwargs)
361b16049aSZach Atkins
371b16049aSZach Atkins    def __call__(self, parser, namespace, values, option_string=None):
381b16049aSZach Atkins        if isinstance(values, str):
391b16049aSZach Atkins            values = self.enum_type(values.lower())
401b16049aSZach Atkins        else:
411b16049aSZach Atkins            values = [self.enum_type(v.lower()) for v in values]
421b16049aSZach Atkins        setattr(namespace, self.dest, values)
431b16049aSZach Atkins
441b16049aSZach Atkins
451b16049aSZach Atkins@dataclass
461b16049aSZach Atkinsclass TestSpec:
471b16049aSZach Atkins    """Dataclass storing information about a single test case"""
481b16049aSZach Atkins    name: str
498938a869SZach Atkins    only: List = field(default_factory=list)
508938a869SZach Atkins    args: List = field(default_factory=list)
511b16049aSZach Atkins
521b16049aSZach Atkins
531b16049aSZach Atkinsclass RunMode(str, Enum):
541b16049aSZach Atkins    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
551b16049aSZach Atkins    __str__ = str.__str__
561b16049aSZach Atkins    __format__ = str.__format__
571b16049aSZach Atkins    TAP: str = 'tap'
581b16049aSZach Atkins    JUNIT: str = 'junit'
591b16049aSZach Atkins
601b16049aSZach Atkins
611b16049aSZach Atkinsclass SuiteSpec(ABC):
621b16049aSZach Atkins    """Abstract Base Class defining the required interface for running a test suite"""
631b16049aSZach Atkins    @abstractmethod
641b16049aSZach Atkins    def get_source_path(self, test: str) -> Path:
651b16049aSZach Atkins        """Compute path to test source file
661b16049aSZach Atkins
671b16049aSZach Atkins        Args:
681b16049aSZach Atkins            test (str): Name of test
691b16049aSZach Atkins
701b16049aSZach Atkins        Returns:
711b16049aSZach Atkins            Path: Path to source file
721b16049aSZach Atkins        """
731b16049aSZach Atkins        raise NotImplementedError
741b16049aSZach Atkins
751b16049aSZach Atkins    @abstractmethod
761b16049aSZach Atkins    def get_run_path(self, test: str) -> Path:
771b16049aSZach Atkins        """Compute path to built test executable file
781b16049aSZach Atkins
791b16049aSZach Atkins        Args:
801b16049aSZach Atkins            test (str): Name of test
811b16049aSZach Atkins
821b16049aSZach Atkins        Returns:
831b16049aSZach Atkins            Path: Path to test executable
841b16049aSZach Atkins        """
851b16049aSZach Atkins        raise NotImplementedError
861b16049aSZach Atkins
871b16049aSZach Atkins    @abstractmethod
881b16049aSZach Atkins    def get_output_path(self, test: str, output_file: str) -> Path:
891b16049aSZach Atkins        """Compute path to expected output file
901b16049aSZach Atkins
911b16049aSZach Atkins        Args:
921b16049aSZach Atkins            test (str): Name of test
931b16049aSZach Atkins            output_file (str): File name of output file
941b16049aSZach Atkins
951b16049aSZach Atkins        Returns:
961b16049aSZach Atkins            Path: Path to expected output file
971b16049aSZach Atkins        """
981b16049aSZach Atkins        raise NotImplementedError
991b16049aSZach Atkins
100c0ad81e5SJeremy L Thompson    @property
101c0ad81e5SJeremy L Thompson    def cgns_tol(self):
102c0ad81e5SJeremy L Thompson        """Absolute tolerance for CGNS diff"""
103c0ad81e5SJeremy L Thompson        return getattr(self, '_cgns_tol', 1.0e-12)
10483ebc4c4SJeremy L Thompson
105c0ad81e5SJeremy L Thompson    @cgns_tol.setter
106c0ad81e5SJeremy L Thompson    def cgns_tol(self, val):
107c0ad81e5SJeremy L Thompson        self._cgns_tol = val
10883ebc4c4SJeremy L Thompson
109*12235d7fSJames Wright    @property
110*12235d7fSJames Wright    def diff_csv_kwargs(self):
111*12235d7fSJames Wright        """Keyword arguments to be passed to diff_csv()"""
112*12235d7fSJames Wright        return getattr(self, '_diff_csv_kwargs', {})
113*12235d7fSJames Wright
114*12235d7fSJames Wright    @diff_csv_kwargs.setter
115*12235d7fSJames Wright    def diff_csv_kwargs(self, val):
116*12235d7fSJames Wright        self._diff_csv_kwargs = val
117*12235d7fSJames Wright
1181b16049aSZach Atkins    def post_test_hook(self, test: str, spec: TestSpec) -> None:
1191b16049aSZach Atkins        """Function callback ran after each test case
1201b16049aSZach Atkins
1211b16049aSZach Atkins        Args:
1221b16049aSZach Atkins            test (str): Name of test
1231b16049aSZach Atkins            spec (TestSpec): Test case specification
1241b16049aSZach Atkins        """
1251b16049aSZach Atkins        pass
1261b16049aSZach Atkins
1271b16049aSZach Atkins    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1281b16049aSZach Atkins        """Check if a test case should be skipped prior to running, returning the reason for skipping
1291b16049aSZach Atkins
1301b16049aSZach Atkins        Args:
1311b16049aSZach Atkins            test (str): Name of test
1321b16049aSZach Atkins            spec (TestSpec): Test case specification
1331b16049aSZach Atkins            resource (str): libCEED backend
1341b16049aSZach Atkins            nproc (int): Number of MPI processes to use when running test case
1351b16049aSZach Atkins
1361b16049aSZach Atkins        Returns:
1371b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if test case should not be skipped
1381b16049aSZach Atkins        """
1391b16049aSZach Atkins        return None
1401b16049aSZach Atkins
1411b16049aSZach Atkins    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1421b16049aSZach Atkins        """Check if a test case should be allowed to fail, based on its stderr output
1431b16049aSZach Atkins
1441b16049aSZach Atkins        Args:
1451b16049aSZach Atkins            test (str): Name of test
1461b16049aSZach Atkins            spec (TestSpec): Test case specification
1471b16049aSZach Atkins            resource (str): libCEED backend
1481b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1491b16049aSZach Atkins
1501b16049aSZach Atkins        Returns:
15119868e18SZach Atkins            Optional[str]: Skip reason, or `None` if unexpected error
1521b16049aSZach Atkins        """
1531b16049aSZach Atkins        return None
1541b16049aSZach Atkins
15578cb100bSJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
1561b16049aSZach Atkins        """Check whether a test case is expected to fail and if it failed expectedly
1571b16049aSZach Atkins
1581b16049aSZach Atkins        Args:
1591b16049aSZach Atkins            test (str): Name of test
1601b16049aSZach Atkins            spec (TestSpec): Test case specification
1611b16049aSZach Atkins            resource (str): libCEED backend
1621b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1631b16049aSZach Atkins
1641b16049aSZach Atkins        Returns:
1651b16049aSZach Atkins            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
1661b16049aSZach Atkins        """
1671b16049aSZach Atkins        return '', True
1681b16049aSZach Atkins
1691b16049aSZach Atkins    def check_allowed_stdout(self, test: str) -> bool:
1701b16049aSZach Atkins        """Check whether a test is allowed to print console output
1711b16049aSZach Atkins
1721b16049aSZach Atkins        Args:
1731b16049aSZach Atkins            test (str): Name of test
1741b16049aSZach Atkins
1751b16049aSZach Atkins        Returns:
1761b16049aSZach Atkins            bool: True if the test is allowed to print console output
1771b16049aSZach Atkins        """
1781b16049aSZach Atkins        return False
1791b16049aSZach Atkins
1801b16049aSZach Atkins
1811b16049aSZach Atkinsdef has_cgnsdiff() -> bool:
1821b16049aSZach Atkins    """Check whether `cgnsdiff` is an executable program in the current environment
1831b16049aSZach Atkins
1841b16049aSZach Atkins    Returns:
1851b16049aSZach Atkins        bool: True if `cgnsdiff` is found
1861b16049aSZach Atkins    """
1871b16049aSZach Atkins    my_env: dict = os.environ.copy()
1881b16049aSZach Atkins    proc = subprocess.run('cgnsdiff',
1891b16049aSZach Atkins                          shell=True,
1901b16049aSZach Atkins                          stdout=subprocess.PIPE,
1911b16049aSZach Atkins                          stderr=subprocess.PIPE,
1921b16049aSZach Atkins                          env=my_env)
1931b16049aSZach Atkins    return 'not found' not in proc.stderr.decode('utf-8')
1941b16049aSZach Atkins
1951b16049aSZach Atkins
19678cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
1971b16049aSZach Atkins    """Helper function, checks if any of the substrings are included in the base string
1981b16049aSZach Atkins
1991b16049aSZach Atkins    Args:
2001b16049aSZach Atkins        base (str): Base string to search in
2018938a869SZach Atkins        substrings (List[str]): List of potential substrings
2021b16049aSZach Atkins
2031b16049aSZach Atkins    Returns:
2041b16049aSZach Atkins        bool: True if any substrings are included in base string
2051b16049aSZach Atkins    """
2061b16049aSZach Atkins    return any((sub in base for sub in substrings))
2071b16049aSZach Atkins
2081b16049aSZach Atkins
20978cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
2101b16049aSZach Atkins    """Helper function, checks if the base string is prefixed by any of `prefixes`
2111b16049aSZach Atkins
2121b16049aSZach Atkins    Args:
2131b16049aSZach Atkins        base (str): Base string to search
2148938a869SZach Atkins        prefixes (List[str]): List of potential prefixes
2151b16049aSZach Atkins
2161b16049aSZach Atkins    Returns:
2171b16049aSZach Atkins        bool: True if base string is prefixed by any of the prefixes
2181b16049aSZach Atkins    """
2191b16049aSZach Atkins    return any((base.startswith(prefix) for prefix in prefixes))
2201b16049aSZach Atkins
2211b16049aSZach Atkins
2221b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec:
2231b16049aSZach Atkins    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
2241b16049aSZach Atkins
2251b16049aSZach Atkins    Args:
2261b16049aSZach Atkins        line (str): String containing TESTARGS specification and CLI arguments
2271b16049aSZach Atkins
2281b16049aSZach Atkins    Returns:
2291b16049aSZach Atkins        TestSpec: Parsed specification of test case
2301b16049aSZach Atkins    """
23178cb100bSJames Wright    args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip())
2321b16049aSZach Atkins    if args[0] == 'TESTARGS':
2331b16049aSZach Atkins        return TestSpec(name='', args=args[1:])
2341b16049aSZach Atkins    raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')]
2351b16049aSZach Atkins    # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'}
2361b16049aSZach Atkins    test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)])
237f85e4a7bSJeremy L Thompson    name: str = test_args.get('name', '')
23878cb100bSJames Wright    constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else []
2391b16049aSZach Atkins    if len(args) > 1:
240f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints, args=args[1:])
2411b16049aSZach Atkins    else:
242f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints)
2431b16049aSZach Atkins
2441b16049aSZach Atkins
24578cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
2461b16049aSZach Atkins    """Parse all test cases from a given source file
2471b16049aSZach Atkins
2481b16049aSZach Atkins    Args:
2491b16049aSZach Atkins        source_file (Path): Path to source file
2501b16049aSZach Atkins
2511b16049aSZach Atkins    Raises:
2521b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
2531b16049aSZach Atkins
2541b16049aSZach Atkins    Returns:
2558938a869SZach Atkins        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
2561b16049aSZach Atkins    """
2571b16049aSZach Atkins    comment_str: str = ''
2588c81f8b0SPeter Munch    if source_file.suffix in ['.c', '.cc', '.cpp']:
2591b16049aSZach Atkins        comment_str = '//'
2601b16049aSZach Atkins    elif source_file.suffix in ['.py']:
2611b16049aSZach Atkins        comment_str = '#'
2621b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
2631b16049aSZach Atkins        comment_str = 'C_'
2641b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
2651b16049aSZach Atkins        comment_str = '! '
2661b16049aSZach Atkins    else:
2671b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
2681b16049aSZach Atkins
2691b16049aSZach Atkins    return [parse_test_line(line.strip(comment_str))
2701b16049aSZach Atkins            for line in source_file.read_text().splitlines()
2711b16049aSZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
2721b16049aSZach Atkins
2731b16049aSZach Atkins
274*12235d7fSJames Wrightdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2,
275*12235d7fSJames Wright             comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str:
2761b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
2771b16049aSZach Atkins
2781b16049aSZach Atkins    Args:
2791b16049aSZach Atkins        test_csv (Path): Path to output CSV results
2801b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
2811b16049aSZach Atkins        zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10.
2821b16049aSZach Atkins        rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2.
283*12235d7fSJames Wright        comment_str (str, optional): String to denoting commented line
284*12235d7fSJames Wright        comment_func (Callable, optional): Function to determine if test and true line are different
2851b16049aSZach Atkins
2861b16049aSZach Atkins    Returns:
2871b16049aSZach Atkins        str: Diff output between result and expected CSVs
2881b16049aSZach Atkins    """
28978cb100bSJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
29078cb100bSJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
29169ef23b6SZach Atkins    # Files should not be empty
29269ef23b6SZach Atkins    if len(test_lines) == 0:
29369ef23b6SZach Atkins        return f'No lines found in test output {test_csv}'
29469ef23b6SZach Atkins    if len(true_lines) == 0:
29569ef23b6SZach Atkins        return f'No lines found in test source {true_csv}'
296*12235d7fSJames Wright    if len(test_lines) != len(true_lines):
297*12235d7fSJames Wright        return f'Number of lines in {test_csv} and {true_csv} do not match'
298*12235d7fSJames Wright
299*12235d7fSJames Wright    # Process commented lines
300*12235d7fSJames Wright    uncommented_lines: List[int] = []
301*12235d7fSJames Wright    for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)):
302*12235d7fSJames Wright        if test_line[0] == comment_str and true_line[0] == comment_str:
303*12235d7fSJames Wright            if comment_func:
304*12235d7fSJames Wright                output = comment_func(test_line, true_line)
305*12235d7fSJames Wright                if output:
306*12235d7fSJames Wright                    return output
307*12235d7fSJames Wright        elif test_line[0] == comment_str and true_line[0] != comment_str:
308*12235d7fSJames Wright            return f'Commented line found in {test_csv} at line {n} but not in {true_csv}'
309*12235d7fSJames Wright        elif test_line[0] != comment_str and true_line[0] == comment_str:
310*12235d7fSJames Wright            return f'Commented line found in {true_csv} at line {n} but not in {test_csv}'
311*12235d7fSJames Wright        else:
312*12235d7fSJames Wright            uncommented_lines.append(n)
313*12235d7fSJames Wright
314*12235d7fSJames Wright    # Remove commented lines
315*12235d7fSJames Wright    test_lines = [test_lines[line] for line in uncommented_lines]
316*12235d7fSJames Wright    true_lines = [true_lines[line] for line in uncommented_lines]
3171b16049aSZach Atkins
31869ef23b6SZach Atkins    test_reader: csv.DictReader = csv.DictReader(test_lines)
31969ef23b6SZach Atkins    true_reader: csv.DictReader = csv.DictReader(true_lines)
32069ef23b6SZach Atkins    if test_reader.fieldnames != true_reader.fieldnames:
3211b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
3221b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
3231b16049aSZach Atkins
32478cb100bSJames Wright    diff_lines: List[str] = list()
32569ef23b6SZach Atkins    for test_line, true_line in zip(test_reader, true_reader):
32669ef23b6SZach Atkins        for key in test_reader.fieldnames:
32769ef23b6SZach Atkins            # Check if the value is numeric
32869ef23b6SZach Atkins            try:
32969ef23b6SZach Atkins                true_val: float = float(true_line[key])
33069ef23b6SZach Atkins                test_val: float = float(test_line[key])
3311b16049aSZach Atkins                true_zero: bool = abs(true_val) < zero_tol
3321b16049aSZach Atkins                test_zero: bool = abs(test_val) < zero_tol
3331b16049aSZach Atkins                fail: bool = False
3341b16049aSZach Atkins                if true_zero:
3351b16049aSZach Atkins                    fail = not test_zero
3361b16049aSZach Atkins                else:
3371b16049aSZach Atkins                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
3381b16049aSZach Atkins                if fail:
33969ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
34069ef23b6SZach Atkins            except ValueError:
34169ef23b6SZach Atkins                if test_line[key] != true_line[key]:
34269ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
34369ef23b6SZach Atkins
3441b16049aSZach Atkins    return '\n'.join(diff_lines)
3451b16049aSZach Atkins
3461b16049aSZach Atkins
34783ebc4c4SJeremy L Thompsondef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float = 1e-12) -> str:
3481b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
3491b16049aSZach Atkins
3501b16049aSZach Atkins    Args:
3511b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
3521b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
35383ebc4c4SJeremy L Thompson        cgns_tol (float, optional): Tolerance for comparing floating-point values
3541b16049aSZach Atkins
3551b16049aSZach Atkins    Returns:
3561b16049aSZach Atkins        str: Diff output between result and expected CGNS files
3571b16049aSZach Atkins    """
3581b16049aSZach Atkins    my_env: dict = os.environ.copy()
3591b16049aSZach Atkins
36083ebc4c4SJeremy L Thompson    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)]
3611b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
3621b16049aSZach Atkins                          shell=True,
3631b16049aSZach Atkins                          stdout=subprocess.PIPE,
3641b16049aSZach Atkins                          stderr=subprocess.PIPE,
3651b16049aSZach Atkins                          env=my_env)
3661b16049aSZach Atkins
3671b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
3681b16049aSZach Atkins
3691b16049aSZach Atkins
370e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
371e17e35bbSJames Wright                            backend: str, test: str, index: int) -> str:
372e17e35bbSJames Wright    output_str = ''
373e17e35bbSJames Wright    if mode is RunMode.TAP:
374e17e35bbSJames Wright        # print incremental output if TAP mode
375e17e35bbSJames Wright        if test_case.is_skipped():
376e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
377e17e35bbSJames Wright        elif test_case.is_failure() or test_case.is_error():
378e17e35bbSJames Wright            output_str += f'    not ok {index} - {spec.name}, {backend}\n'
379e17e35bbSJames Wright        else:
380e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend}\n'
381e17e35bbSJames Wright        output_str += f'      ---\n'
382e17e35bbSJames Wright        if spec.only:
383e17e35bbSJames Wright            output_str += f'      only: {",".join(spec.only)}\n'
384e17e35bbSJames Wright        output_str += f'      args: {test_case.args}\n'
385e17e35bbSJames Wright        if test_case.is_error():
386e17e35bbSJames Wright            output_str += f'      error: {test_case.errors[0]["message"]}\n'
387e17e35bbSJames Wright        if test_case.is_failure():
388e17e35bbSJames Wright            output_str += f'      num_failures: {len(test_case.failures)}\n'
389e17e35bbSJames Wright            for i, failure in enumerate(test_case.failures):
390e17e35bbSJames Wright                output_str += f'      failure_{i}: {failure["message"]}\n'
391e17e35bbSJames Wright                output_str += f'        message: {failure["message"]}\n'
392e17e35bbSJames Wright                if failure["output"]:
393e17e35bbSJames Wright                    out = failure["output"].strip().replace('\n', '\n          ')
394e17e35bbSJames Wright                    output_str += f'        output: |\n          {out}\n'
395e17e35bbSJames Wright        output_str += f'      ...\n'
396e17e35bbSJames Wright    else:
397e17e35bbSJames Wright        # print error or failure information if JUNIT mode
398e17e35bbSJames Wright        if test_case.is_error() or test_case.is_failure():
399e17e35bbSJames Wright            output_str += f'Test: {test} {spec.name}\n'
400e17e35bbSJames Wright            output_str += f'  $ {test_case.args}\n'
401e17e35bbSJames Wright            if test_case.is_error():
402e17e35bbSJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
403e17e35bbSJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
404e17e35bbSJames Wright            if test_case.is_failure():
405e17e35bbSJames Wright                for failure in test_case.failures:
406e17e35bbSJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
407e17e35bbSJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
408e17e35bbSJames Wright    return output_str
409e17e35bbSJames Wright
410e17e35bbSJames Wright
41119868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str,
41219868e18SZach Atkins             mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase:
41319868e18SZach Atkins    """Run a single test case and backend combination
4141b16049aSZach Atkins
4151b16049aSZach Atkins    Args:
4168938a869SZach Atkins        index (int): Index of backend for current spec
41719868e18SZach Atkins        test (str): Path to test
41819868e18SZach Atkins        spec (TestSpec): Specification of test case
41919868e18SZach Atkins        backend (str): CEED backend
42019868e18SZach Atkins        mode (RunMode): Output mode
42119868e18SZach Atkins        nproc (int): Number of MPI processes to use when running test case
42219868e18SZach Atkins        suite_spec (SuiteSpec): Specification of test suite
4231b16049aSZach Atkins
4241b16049aSZach Atkins    Returns:
42519868e18SZach Atkins        TestCase: Test case result
4261b16049aSZach Atkins    """
4271b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
4288938a869SZach Atkins    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
4291b16049aSZach Atkins
4301b16049aSZach Atkins    if '{ceed_resource}' in run_args:
43119868e18SZach Atkins        run_args[run_args.index('{ceed_resource}')] = backend
4328938a869SZach Atkins    for i, arg in enumerate(run_args):
4338938a869SZach Atkins        if '{ceed_resource}' in arg:
4348938a869SZach Atkins            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
4351b16049aSZach Atkins    if '{nproc}' in run_args:
4361b16049aSZach Atkins        run_args[run_args.index('{nproc}')] = f'{nproc}'
4371b16049aSZach Atkins    elif nproc > 1 and source_path.suffix != '.py':
4381b16049aSZach Atkins        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
4391b16049aSZach Atkins
4401b16049aSZach Atkins    # run test
44119868e18SZach Atkins    skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc)
4421b16049aSZach Atkins    if skip_reason:
44319868e18SZach Atkins        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
4441b16049aSZach Atkins                                       elapsed_sec=0,
4451b16049aSZach Atkins                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
4461b16049aSZach Atkins                                       stdout='',
4478938a869SZach Atkins                                       stderr='',
4488938a869SZach Atkins                                       category=spec.name,)
4491b16049aSZach Atkins        test_case.add_skipped_info(skip_reason)
4501b16049aSZach Atkins    else:
4511b16049aSZach Atkins        start: float = time.time()
4521b16049aSZach Atkins        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
4531b16049aSZach Atkins                              shell=True,
4541b16049aSZach Atkins                              stdout=subprocess.PIPE,
4551b16049aSZach Atkins                              stderr=subprocess.PIPE,
4561b16049aSZach Atkins                              env=my_env)
4571b16049aSZach Atkins
45819868e18SZach Atkins        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
4591b16049aSZach Atkins                             classname=source_path.parent,
4601b16049aSZach Atkins                             elapsed_sec=time.time() - start,
4611b16049aSZach Atkins                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
4621b16049aSZach Atkins                             stdout=proc.stdout.decode('utf-8'),
4631b16049aSZach Atkins                             stderr=proc.stderr.decode('utf-8'),
4648938a869SZach Atkins                             allow_multiple_subelements=True,
4658938a869SZach Atkins                             category=spec.name,)
46678cb100bSJames Wright        ref_csvs: List[Path] = []
4678938a869SZach Atkins        output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg]
46897fab443SJeremy L Thompson        if output_files:
469*12235d7fSJames Wright            ref_csvs = [suite_spec.get_output_path(test, file.split(':')[1]) for file in output_files]
47078cb100bSJames Wright        ref_cgns: List[Path] = []
4718938a869SZach Atkins        output_files = [arg for arg in run_args if 'cgns:' in arg]
47297fab443SJeremy L Thompson        if output_files:
4731b16049aSZach Atkins            ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
4741b16049aSZach Atkins        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
4751b16049aSZach Atkins        suite_spec.post_test_hook(test, spec)
4761b16049aSZach Atkins
4771b16049aSZach Atkins    # check allowed failures
4781b16049aSZach Atkins    if not test_case.is_skipped() and test_case.stderr:
47919868e18SZach Atkins        skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
4801b16049aSZach Atkins        if skip_reason:
4811b16049aSZach Atkins            test_case.add_skipped_info(skip_reason)
4821b16049aSZach Atkins
4831b16049aSZach Atkins    # check required failures
4841b16049aSZach Atkins    if not test_case.is_skipped():
4852fee3251SSebastian Grimberg        required_message, did_fail = suite_spec.check_required_failure(
48619868e18SZach Atkins            test, spec, backend, test_case.stderr)
4871b16049aSZach Atkins        if required_message and did_fail:
4881b16049aSZach Atkins            test_case.status = f'fails with required: {required_message}'
4891b16049aSZach Atkins        elif required_message:
4901b16049aSZach Atkins            test_case.add_failure_info(f'required failure missing: {required_message}')
4911b16049aSZach Atkins
4921b16049aSZach Atkins    # classify other results
4931b16049aSZach Atkins    if not test_case.is_skipped() and not test_case.status:
4941b16049aSZach Atkins        if test_case.stderr:
4951b16049aSZach Atkins            test_case.add_failure_info('stderr', test_case.stderr)
4961b16049aSZach Atkins        if proc.returncode != 0:
4971b16049aSZach Atkins            test_case.add_error_info(f'returncode = {proc.returncode}')
4981b16049aSZach Atkins        if ref_stdout.is_file():
4991b16049aSZach Atkins            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
5001b16049aSZach Atkins                                             test_case.stdout.splitlines(keepends=True),
5011b16049aSZach Atkins                                             fromfile=str(ref_stdout),
5021b16049aSZach Atkins                                             tofile='New'))
5031b16049aSZach Atkins            if diff:
5041b16049aSZach Atkins                test_case.add_failure_info('stdout', output=''.join(diff))
5051b16049aSZach Atkins        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
5061b16049aSZach Atkins            test_case.add_failure_info('stdout', output=test_case.stdout)
5071b16049aSZach Atkins        # expected CSV output
5081b16049aSZach Atkins        for ref_csv in ref_csvs:
5098938a869SZach Atkins            csv_name = ref_csv.name
5108938a869SZach Atkins            if not ref_csv.is_file():
5118938a869SZach Atkins                # remove _{ceed_backend} from path name
5128938a869SZach Atkins                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
5131b16049aSZach Atkins            if not ref_csv.is_file():
5141b16049aSZach Atkins                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
515ecceccc8SJeremy L Thompson            elif not (Path.cwd() / csv_name).is_file():
516ecceccc8SJeremy L Thompson                test_case.add_failure_info('csv', output=f'{csv_name} not found')
5171b16049aSZach Atkins            else:
518*12235d7fSJames Wright                diff: str = diff_csv(Path.cwd() / csv_name, ref_csv, **suite_spec.diff_csv_kwargs)
5191b16049aSZach Atkins                if diff:
5201b16049aSZach Atkins                    test_case.add_failure_info('csv', output=diff)
5211b16049aSZach Atkins                else:
5228938a869SZach Atkins                    (Path.cwd() / csv_name).unlink()
5231b16049aSZach Atkins        # expected CGNS output
5241b16049aSZach Atkins        for ref_cgn in ref_cgns:
5258938a869SZach Atkins            cgn_name = ref_cgn.name
5268938a869SZach Atkins            if not ref_cgn.is_file():
5278938a869SZach Atkins                # remove _{ceed_backend} from path name
5288938a869SZach Atkins                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
5291b16049aSZach Atkins            if not ref_cgn.is_file():
5301b16049aSZach Atkins                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
531ecceccc8SJeremy L Thompson            elif not (Path.cwd() / cgn_name).is_file():
532ecceccc8SJeremy L Thompson                test_case.add_failure_info('csv', output=f'{cgn_name} not found')
5331b16049aSZach Atkins            else:
534c0ad81e5SJeremy L Thompson                diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn, cgns_tol=suite_spec.cgns_tol)
5351b16049aSZach Atkins                if diff:
5361b16049aSZach Atkins                    test_case.add_failure_info('cgns', output=diff)
5371b16049aSZach Atkins                else:
5388938a869SZach Atkins                    (Path.cwd() / cgn_name).unlink()
5391b16049aSZach Atkins
5401b16049aSZach Atkins    # store result
5411b16049aSZach Atkins    test_case.args = ' '.join(str(arg) for arg in run_args)
542e17e35bbSJames Wright    output_str = test_case_output_string(test_case, spec, mode, backend, test, index)
54319868e18SZach Atkins
54419868e18SZach Atkins    return test_case, output_str
54519868e18SZach Atkins
54619868e18SZach Atkins
54719868e18SZach Atkinsdef init_process():
54819868e18SZach Atkins    """Initialize multiprocessing process"""
54919868e18SZach Atkins    # set up error handler
55019868e18SZach Atkins    global my_env
55119868e18SZach Atkins    my_env = os.environ.copy()
55219868e18SZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
55319868e18SZach Atkins
55419868e18SZach Atkins
55578cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
55619868e18SZach Atkins              suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite:
55719868e18SZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
55819868e18SZach Atkins
55919868e18SZach Atkins    Args:
56019868e18SZach Atkins        test (str): Name of test
5618938a869SZach Atkins        ceed_backends (List[str]): List of libCEED backends
56219868e18SZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
56319868e18SZach Atkins        nproc (int): Number of MPI processes to use when running each test case
56419868e18SZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
56519868e18SZach Atkins        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
56619868e18SZach Atkins
56719868e18SZach Atkins    Returns:
56819868e18SZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
56919868e18SZach Atkins    """
57078cb100bSJames Wright    test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test))
57119868e18SZach Atkins    if mode is RunMode.TAP:
5728938a869SZach Atkins        print('TAP version 13')
5738938a869SZach Atkins        print(f'1..{len(test_specs)}')
57419868e18SZach Atkins
57519868e18SZach Atkins    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
5768938a869SZach Atkins        async_outputs: List[List[mp.AsyncResult]] = [
5778938a869SZach Atkins            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec))
5788938a869SZach Atkins             for (i, backend) in enumerate(ceed_backends, start=1)]
5798938a869SZach Atkins            for spec in test_specs
5808938a869SZach Atkins        ]
58119868e18SZach Atkins
58219868e18SZach Atkins        test_cases = []
5838938a869SZach Atkins        for (i, subtest) in enumerate(async_outputs, start=1):
5848938a869SZach Atkins            is_new_subtest = True
5858938a869SZach Atkins            subtest_ok = True
5868938a869SZach Atkins            for async_output in subtest:
58719868e18SZach Atkins                test_case, print_output = async_output.get()
58819868e18SZach Atkins                test_cases.append(test_case)
5898938a869SZach Atkins                if is_new_subtest and mode == RunMode.TAP:
5908938a869SZach Atkins                    is_new_subtest = False
5918938a869SZach Atkins                    print(f'# Subtest: {test_case.category}')
5928938a869SZach Atkins                    print(f'    1..{len(ceed_backends)}')
59319868e18SZach Atkins                print(print_output, end='')
5948938a869SZach Atkins                if test_case.is_failure() or test_case.is_error():
5958938a869SZach Atkins                    subtest_ok = False
5968938a869SZach Atkins            if mode == RunMode.TAP:
5978938a869SZach Atkins                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
5981b16049aSZach Atkins
5991b16049aSZach Atkins    return TestSuite(test, test_cases)
6001b16049aSZach Atkins
6011b16049aSZach Atkins
6021b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
6031b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
6041b16049aSZach Atkins
6051b16049aSZach Atkins    Args:
6061b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
6071b16049aSZach Atkins        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
6081b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
6091b16049aSZach Atkins    """
6101b16049aSZach Atkins    output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
6111b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
6121b16049aSZach Atkins
6131b16049aSZach Atkins
6141b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
6151b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
6161b16049aSZach Atkins
6171b16049aSZach Atkins    Args:
6181b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
6191b16049aSZach Atkins
6201b16049aSZach Atkins    Returns:
6211b16049aSZach Atkins        bool: True if any test cases failed
6221b16049aSZach Atkins    """
6231b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
624