xref: /libCEED/tests/junit_common.py (revision f36e753100926c940e21f506e95c0b0794520501)
11b16049aSZach Atkinsfrom abc import ABC, abstractmethod
2*f36e7531SZach Atkinsfrom collections.abc import Iterable
31b16049aSZach Atkinsimport argparse
469ef23b6SZach Atkinsimport csv
5*f36e7531SZach Atkinsfrom dataclasses import dataclass, field, fields
61b16049aSZach Atkinsimport difflib
71b16049aSZach Atkinsfrom enum import Enum
81b16049aSZach Atkinsfrom math import isclose
91b16049aSZach Atkinsimport os
101b16049aSZach Atkinsfrom pathlib import Path
111b16049aSZach Atkinsimport re
121b16049aSZach Atkinsimport subprocess
1319868e18SZach Atkinsimport multiprocessing as mp
141b16049aSZach Atkinsimport sys
151b16049aSZach Atkinsimport time
16*f36e7531SZach Atkinsfrom typing import Optional, Tuple, List, Dict, Callable, Iterable, get_origin
17*f36e7531SZach Atkinsimport shutil
181b16049aSZach Atkins
191b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
201b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
211b16049aSZach Atkins
221b16049aSZach Atkins
23*f36e7531SZach Atkinsclass ParseError(RuntimeError):
24*f36e7531SZach Atkins    """A custom exception for failed parsing."""
25*f36e7531SZach Atkins
26*f36e7531SZach Atkins    def __init__(self, message):
27*f36e7531SZach Atkins        super().__init__(message)
28*f36e7531SZach Atkins
29*f36e7531SZach Atkins
301b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action):
311b16049aSZach Atkins    """Action to convert input values to lower case prior to converting to an Enum type"""
321b16049aSZach Atkins
331b16049aSZach Atkins    def __init__(self, option_strings, dest, type, default, **kwargs):
34*f36e7531SZach Atkins        if not issubclass(type, Enum):
35*f36e7531SZach Atkins            raise ValueError(f"{type} must be an Enum")
361b16049aSZach Atkins        # store provided enum type
371b16049aSZach Atkins        self.enum_type = type
38*f36e7531SZach Atkins        if isinstance(default, self.enum_type):
39*f36e7531SZach Atkins            pass
40*f36e7531SZach Atkins        elif isinstance(default, str):
411b16049aSZach Atkins            default = self.enum_type(default.lower())
42*f36e7531SZach Atkins        elif isinstance(default, Iterable):
431b16049aSZach Atkins            default = [self.enum_type(v.lower()) for v in default]
44*f36e7531SZach Atkins        else:
45*f36e7531SZach Atkins            raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable")
461b16049aSZach Atkins        # prevent automatic type conversion
471b16049aSZach Atkins        super().__init__(option_strings, dest, default=default, **kwargs)
481b16049aSZach Atkins
491b16049aSZach Atkins    def __call__(self, parser, namespace, values, option_string=None):
50*f36e7531SZach Atkins        if isinstance(values, self.enum_type):
51*f36e7531SZach Atkins            pass
52*f36e7531SZach Atkins        elif isinstance(values, str):
531b16049aSZach Atkins            values = self.enum_type(values.lower())
54*f36e7531SZach Atkins        elif isinstance(values, Iterable):
551b16049aSZach Atkins            values = [self.enum_type(v.lower()) for v in values]
56*f36e7531SZach Atkins        else:
57*f36e7531SZach Atkins            raise argparse.ArgumentTypeError("Invalid value type, must be str or iterable")
581b16049aSZach Atkins        setattr(namespace, self.dest, values)
591b16049aSZach Atkins
601b16049aSZach Atkins
611b16049aSZach Atkins@dataclass
621b16049aSZach Atkinsclass TestSpec:
631b16049aSZach Atkins    """Dataclass storing information about a single test case"""
64*f36e7531SZach Atkins    name: str = field(default_factory=str)
65*f36e7531SZach Atkins    csv_rtol: float = -1
66*f36e7531SZach Atkins    csv_ztol: float = -1
67*f36e7531SZach Atkins    cgns_tol: float = -1
688938a869SZach Atkins    only: List = field(default_factory=list)
698938a869SZach Atkins    args: List = field(default_factory=list)
70*f36e7531SZach Atkins    key_values: Dict = field(default_factory=dict)
711b16049aSZach Atkins
721b16049aSZach Atkins
73*f36e7531SZach Atkinsclass RunMode(Enum):
741b16049aSZach Atkins    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
75*f36e7531SZach Atkins    TAP = 'tap'
76*f36e7531SZach Atkins    JUNIT = 'junit'
77*f36e7531SZach Atkins
78*f36e7531SZach Atkins    def __str__(self):
79*f36e7531SZach Atkins        return self.value
80*f36e7531SZach Atkins
81*f36e7531SZach Atkins    def __repr__(self):
82*f36e7531SZach Atkins        return self.value
831b16049aSZach Atkins
841b16049aSZach Atkins
851b16049aSZach Atkinsclass SuiteSpec(ABC):
861b16049aSZach Atkins    """Abstract Base Class defining the required interface for running a test suite"""
871b16049aSZach Atkins    @abstractmethod
881b16049aSZach Atkins    def get_source_path(self, test: str) -> Path:
891b16049aSZach Atkins        """Compute path to test source file
901b16049aSZach Atkins
911b16049aSZach Atkins        Args:
921b16049aSZach Atkins            test (str): Name of test
931b16049aSZach Atkins
941b16049aSZach Atkins        Returns:
951b16049aSZach Atkins            Path: Path to source file
961b16049aSZach Atkins        """
971b16049aSZach Atkins        raise NotImplementedError
981b16049aSZach Atkins
991b16049aSZach Atkins    @abstractmethod
1001b16049aSZach Atkins    def get_run_path(self, test: str) -> Path:
1011b16049aSZach Atkins        """Compute path to built test executable file
1021b16049aSZach Atkins
1031b16049aSZach Atkins        Args:
1041b16049aSZach Atkins            test (str): Name of test
1051b16049aSZach Atkins
1061b16049aSZach Atkins        Returns:
1071b16049aSZach Atkins            Path: Path to test executable
1081b16049aSZach Atkins        """
1091b16049aSZach Atkins        raise NotImplementedError
1101b16049aSZach Atkins
1111b16049aSZach Atkins    @abstractmethod
1121b16049aSZach Atkins    def get_output_path(self, test: str, output_file: str) -> Path:
1131b16049aSZach Atkins        """Compute path to expected output file
1141b16049aSZach Atkins
1151b16049aSZach Atkins        Args:
1161b16049aSZach Atkins            test (str): Name of test
1171b16049aSZach Atkins            output_file (str): File name of output file
1181b16049aSZach Atkins
1191b16049aSZach Atkins        Returns:
1201b16049aSZach Atkins            Path: Path to expected output file
1211b16049aSZach Atkins        """
1221b16049aSZach Atkins        raise NotImplementedError
1231b16049aSZach Atkins
124c0ad81e5SJeremy L Thompson    @property
125*f36e7531SZach Atkins    def test_failure_artifacts_path(self) -> Path:
126*f36e7531SZach Atkins        """Path to test failure artifacts"""
127*f36e7531SZach Atkins        return Path('build') / 'test_failure_artifacts'
128*f36e7531SZach Atkins
129*f36e7531SZach Atkins    @property
130c0ad81e5SJeremy L Thompson    def cgns_tol(self):
131c0ad81e5SJeremy L Thompson        """Absolute tolerance for CGNS diff"""
132c0ad81e5SJeremy L Thompson        return getattr(self, '_cgns_tol', 1.0e-12)
13383ebc4c4SJeremy L Thompson
134c0ad81e5SJeremy L Thompson    @cgns_tol.setter
135c0ad81e5SJeremy L Thompson    def cgns_tol(self, val):
136c0ad81e5SJeremy L Thompson        self._cgns_tol = val
13783ebc4c4SJeremy L Thompson
13812235d7fSJames Wright    @property
139*f36e7531SZach Atkins    def csv_ztol(self):
14012235d7fSJames Wright        """Keyword arguments to be passed to diff_csv()"""
141*f36e7531SZach Atkins        return getattr(self, '_csv_ztol', 3e-10)
14212235d7fSJames Wright
143*f36e7531SZach Atkins    @csv_ztol.setter
144*f36e7531SZach Atkins    def csv_ztol(self, val):
145*f36e7531SZach Atkins        self._csv_ztol = val
14612235d7fSJames Wright
147*f36e7531SZach Atkins    @property
148*f36e7531SZach Atkins    def csv_rtol(self):
149*f36e7531SZach Atkins        """Keyword arguments to be passed to diff_csv()"""
150*f36e7531SZach Atkins        return getattr(self, '_csv_rtol', 1e-6)
151*f36e7531SZach Atkins
152*f36e7531SZach Atkins    @csv_rtol.setter
153*f36e7531SZach Atkins    def csv_rtol(self, val):
154*f36e7531SZach Atkins        self._csv_rtol = val
155*f36e7531SZach Atkins
156*f36e7531SZach Atkins    def post_test_hook(self, test: str, spec: TestSpec, backend: str) -> None:
1571b16049aSZach Atkins        """Function callback ran after each test case
1581b16049aSZach Atkins
1591b16049aSZach Atkins        Args:
1601b16049aSZach Atkins            test (str): Name of test
1611b16049aSZach Atkins            spec (TestSpec): Test case specification
1621b16049aSZach Atkins        """
1631b16049aSZach Atkins        pass
1641b16049aSZach Atkins
1651b16049aSZach Atkins    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1661b16049aSZach Atkins        """Check if a test case should be skipped prior to running, returning the reason for skipping
1671b16049aSZach Atkins
1681b16049aSZach Atkins        Args:
1691b16049aSZach Atkins            test (str): Name of test
1701b16049aSZach Atkins            spec (TestSpec): Test case specification
1711b16049aSZach Atkins            resource (str): libCEED backend
1721b16049aSZach Atkins            nproc (int): Number of MPI processes to use when running test case
1731b16049aSZach Atkins
1741b16049aSZach Atkins        Returns:
1751b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if test case should not be skipped
1761b16049aSZach Atkins        """
1771b16049aSZach Atkins        return None
1781b16049aSZach Atkins
1791b16049aSZach Atkins    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1801b16049aSZach Atkins        """Check if a test case should be allowed to fail, based on its stderr output
1811b16049aSZach Atkins
1821b16049aSZach Atkins        Args:
1831b16049aSZach Atkins            test (str): Name of test
1841b16049aSZach Atkins            spec (TestSpec): Test case specification
1851b16049aSZach Atkins            resource (str): libCEED backend
1861b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1871b16049aSZach Atkins
1881b16049aSZach Atkins        Returns:
18919868e18SZach Atkins            Optional[str]: Skip reason, or `None` if unexpected error
1901b16049aSZach Atkins        """
1911b16049aSZach Atkins        return None
1921b16049aSZach Atkins
19378cb100bSJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
1941b16049aSZach Atkins        """Check whether a test case is expected to fail and if it failed expectedly
1951b16049aSZach Atkins
1961b16049aSZach Atkins        Args:
1971b16049aSZach Atkins            test (str): Name of test
1981b16049aSZach Atkins            spec (TestSpec): Test case specification
1991b16049aSZach Atkins            resource (str): libCEED backend
2001b16049aSZach Atkins            stderr (str): Standard error output from test case execution
2011b16049aSZach Atkins
2021b16049aSZach Atkins        Returns:
2031b16049aSZach Atkins            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
2041b16049aSZach Atkins        """
2051b16049aSZach Atkins        return '', True
2061b16049aSZach Atkins
2071b16049aSZach Atkins    def check_allowed_stdout(self, test: str) -> bool:
2081b16049aSZach Atkins        """Check whether a test is allowed to print console output
2091b16049aSZach Atkins
2101b16049aSZach Atkins        Args:
2111b16049aSZach Atkins            test (str): Name of test
2121b16049aSZach Atkins
2131b16049aSZach Atkins        Returns:
2141b16049aSZach Atkins            bool: True if the test is allowed to print console output
2151b16049aSZach Atkins        """
2161b16049aSZach Atkins        return False
2171b16049aSZach Atkins
2181b16049aSZach Atkins
2191b16049aSZach Atkinsdef has_cgnsdiff() -> bool:
2201b16049aSZach Atkins    """Check whether `cgnsdiff` is an executable program in the current environment
2211b16049aSZach Atkins
2221b16049aSZach Atkins    Returns:
2231b16049aSZach Atkins        bool: True if `cgnsdiff` is found
2241b16049aSZach Atkins    """
2251b16049aSZach Atkins    my_env: dict = os.environ.copy()
2261b16049aSZach Atkins    proc = subprocess.run('cgnsdiff',
2271b16049aSZach Atkins                          shell=True,
2281b16049aSZach Atkins                          stdout=subprocess.PIPE,
2291b16049aSZach Atkins                          stderr=subprocess.PIPE,
2301b16049aSZach Atkins                          env=my_env)
2311b16049aSZach Atkins    return 'not found' not in proc.stderr.decode('utf-8')
2321b16049aSZach Atkins
2331b16049aSZach Atkins
23478cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
2351b16049aSZach Atkins    """Helper function, checks if any of the substrings are included in the base string
2361b16049aSZach Atkins
2371b16049aSZach Atkins    Args:
2381b16049aSZach Atkins        base (str): Base string to search in
2398938a869SZach Atkins        substrings (List[str]): List of potential substrings
2401b16049aSZach Atkins
2411b16049aSZach Atkins    Returns:
2421b16049aSZach Atkins        bool: True if any substrings are included in base string
2431b16049aSZach Atkins    """
2441b16049aSZach Atkins    return any((sub in base for sub in substrings))
2451b16049aSZach Atkins
2461b16049aSZach Atkins
24778cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
2481b16049aSZach Atkins    """Helper function, checks if the base string is prefixed by any of `prefixes`
2491b16049aSZach Atkins
2501b16049aSZach Atkins    Args:
2511b16049aSZach Atkins        base (str): Base string to search
2528938a869SZach Atkins        prefixes (List[str]): List of potential prefixes
2531b16049aSZach Atkins
2541b16049aSZach Atkins    Returns:
2551b16049aSZach Atkins        bool: True if base string is prefixed by any of the prefixes
2561b16049aSZach Atkins    """
2571b16049aSZach Atkins    return any((base.startswith(prefix) for prefix in prefixes))
2581b16049aSZach Atkins
2591b16049aSZach Atkins
260*f36e7531SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]:
261*f36e7531SZach Atkins    """Find the start and end positions of the first outer paired delimeters
262*f36e7531SZach Atkins
263*f36e7531SZach Atkins    Args:
264*f36e7531SZach Atkins        line (str): Line to search
265*f36e7531SZach Atkins        open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('.
266*f36e7531SZach Atkins        close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'.
267*f36e7531SZach Atkins
268*f36e7531SZach Atkins    Raises:
269*f36e7531SZach Atkins        RuntimeError: If open or close is not a single character
270*f36e7531SZach Atkins        RuntimeError: If open and close are the same characters
271*f36e7531SZach Atkins
272*f36e7531SZach Atkins    Returns:
273*f36e7531SZach Atkins        Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start.
274*f36e7531SZach Atkins    """
275*f36e7531SZach Atkins    if len(open) != 1 or len(close) != 1:
276*f36e7531SZach Atkins        raise RuntimeError("`open` and `close` must be single characters")
277*f36e7531SZach Atkins    if open == close:
278*f36e7531SZach Atkins        raise RuntimeError("`open` and `close` must be different characters")
279*f36e7531SZach Atkins    start: int = line.find(open)
280*f36e7531SZach Atkins    if start < 0:
281*f36e7531SZach Atkins        return -1, -1
282*f36e7531SZach Atkins    count: int = 1
283*f36e7531SZach Atkins    for i in range(start + 1, len(line)):
284*f36e7531SZach Atkins        if line[i] == open:
285*f36e7531SZach Atkins            count += 1
286*f36e7531SZach Atkins        if line[i] == close:
287*f36e7531SZach Atkins            count -= 1
288*f36e7531SZach Atkins            if count == 0:
289*f36e7531SZach Atkins                return start, i
290*f36e7531SZach Atkins    return start, -1
291*f36e7531SZach Atkins
292*f36e7531SZach Atkins
2931b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec:
2941b16049aSZach Atkins    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
2951b16049aSZach Atkins
2961b16049aSZach Atkins    Args:
2971b16049aSZach Atkins        line (str): String containing TESTARGS specification and CLI arguments
2981b16049aSZach Atkins
2991b16049aSZach Atkins    Returns:
3001b16049aSZach Atkins        TestSpec: Parsed specification of test case
3011b16049aSZach Atkins    """
302*f36e7531SZach Atkins    test_fields = fields(TestSpec)
303*f36e7531SZach Atkins    field_names = [f.name for f in test_fields]
304*f36e7531SZach Atkins    known: Dict = dict()
305*f36e7531SZach Atkins    other: Dict = dict()
306*f36e7531SZach Atkins    if line[0] == "(":
307*f36e7531SZach Atkins        # have key/value pairs to parse
308*f36e7531SZach Atkins        start, end = find_matching(line)
309*f36e7531SZach Atkins        if end < start:
310*f36e7531SZach Atkins            raise ParseError(f"Mismatched parentheses in TESTCASE: {line}")
311*f36e7531SZach Atkins
312*f36e7531SZach Atkins        keyvalues_str = line[start:end + 1]
313*f36e7531SZach Atkins        keyvalues_pattern = re.compile(r'''
314*f36e7531SZach Atkins            (?:\(\s*|\s*,\s*)   # start with open parentheses or comma, no capture
315*f36e7531SZach Atkins            ([A-Za-z]+[\w\-]+)  # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1
316*f36e7531SZach Atkins            \s*=\s*             # key is followed by = (whitespace ignored)
317*f36e7531SZach Atkins            (?:                 # uncaptured group for OR
318*f36e7531SZach Atkins              "((?:[^"]|\\")+)" #   match quoted value (any internal " must be escaped as \"); captured as Group 2
319*f36e7531SZach Atkins            | ([^=]+)           #   OR match unquoted value (no equals signs allowed); captured as Group 3
320*f36e7531SZach Atkins            )                   # end uncaptured group for OR
321*f36e7531SZach Atkins            \s*(?=,|\))         # lookahead for either next comma or closing parentheses
322*f36e7531SZach Atkins        ''', re.VERBOSE)
323*f36e7531SZach Atkins
324*f36e7531SZach Atkins        for match in re.finditer(keyvalues_pattern, keyvalues_str):
325*f36e7531SZach Atkins            if not match:  # empty
326*f36e7531SZach Atkins                continue
327*f36e7531SZach Atkins            key = match.group(1)
328*f36e7531SZach Atkins            value = match.group(2) if match.group(2) else match.group(3)
329*f36e7531SZach Atkins            try:
330*f36e7531SZach Atkins                index = field_names.index(key)
331*f36e7531SZach Atkins                if key == "only":  # weird bc only is a list
332*f36e7531SZach Atkins                    value = [constraint.strip() for constraint in value.split(',')]
333*f36e7531SZach Atkins                try:
334*f36e7531SZach Atkins                    # TODO: stop supporting python <=3.8
335*f36e7531SZach Atkins                    known[key] = test_fields[index].type(value)  # type: ignore
336*f36e7531SZach Atkins                except TypeError:
337*f36e7531SZach Atkins                    # TODO: this is still liable to fail for complex types
338*f36e7531SZach Atkins                    known[key] = get_origin(test_fields[index].type)(value)  # type: ignore
339*f36e7531SZach Atkins            except ValueError:
340*f36e7531SZach Atkins                other[key] = value
341*f36e7531SZach Atkins
342*f36e7531SZach Atkins        line = line[end + 1:]
343*f36e7531SZach Atkins
344*f36e7531SZach Atkins    args_pattern = re.compile(r'''
345*f36e7531SZach Atkins        \s+(            # remove leading space
346*f36e7531SZach Atkins            (?:"[^"]+") # match quoted CLI option
347*f36e7531SZach Atkins          | (?:[\S]+)   # match anything else that is space separated
348*f36e7531SZach Atkins        )
349*f36e7531SZach Atkins    ''', re.VERBOSE)
350*f36e7531SZach Atkins    args: List[str] = re.findall(args_pattern, line)
351*f36e7531SZach Atkins    for k, v in other.items():
352*f36e7531SZach Atkins        print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}")
353*f36e7531SZach Atkins    return TestSpec(**known, key_values=other, args=args)
3541b16049aSZach Atkins
3551b16049aSZach Atkins
35678cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
3571b16049aSZach Atkins    """Parse all test cases from a given source file
3581b16049aSZach Atkins
3591b16049aSZach Atkins    Args:
3601b16049aSZach Atkins        source_file (Path): Path to source file
3611b16049aSZach Atkins
3621b16049aSZach Atkins    Raises:
3631b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
3641b16049aSZach Atkins
3651b16049aSZach Atkins    Returns:
3668938a869SZach Atkins        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
3671b16049aSZach Atkins    """
3681b16049aSZach Atkins    comment_str: str = ''
3698c81f8b0SPeter Munch    if source_file.suffix in ['.c', '.cc', '.cpp']:
3701b16049aSZach Atkins        comment_str = '//'
3711b16049aSZach Atkins    elif source_file.suffix in ['.py']:
3721b16049aSZach Atkins        comment_str = '#'
3731b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
3741b16049aSZach Atkins        comment_str = 'C_'
3751b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
3761b16049aSZach Atkins        comment_str = '! '
3771b16049aSZach Atkins    else:
3781b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
3791b16049aSZach Atkins
380*f36e7531SZach Atkins    return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS"))
3811b16049aSZach Atkins            for line in source_file.read_text().splitlines()
3821b16049aSZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
3831b16049aSZach Atkins
3841b16049aSZach Atkins
385*f36e7531SZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float, rel_tol: float,
38612235d7fSJames Wright             comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str:
3871b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
3881b16049aSZach Atkins
3891b16049aSZach Atkins    Args:
3901b16049aSZach Atkins        test_csv (Path): Path to output CSV results
3911b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
392*f36e7531SZach Atkins        zero_tol (float): Tolerance below which values are considered to be zero.
393*f36e7531SZach Atkins        rel_tol (float): Relative tolerance for comparing non-zero values.
39412235d7fSJames Wright        comment_str (str, optional): String to denoting commented line
39512235d7fSJames Wright        comment_func (Callable, optional): Function to determine if test and true line are different
3961b16049aSZach Atkins
3971b16049aSZach Atkins    Returns:
3981b16049aSZach Atkins        str: Diff output between result and expected CSVs
3991b16049aSZach Atkins    """
40078cb100bSJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
40178cb100bSJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
40269ef23b6SZach Atkins    # Files should not be empty
40369ef23b6SZach Atkins    if len(test_lines) == 0:
40469ef23b6SZach Atkins        return f'No lines found in test output {test_csv}'
40569ef23b6SZach Atkins    if len(true_lines) == 0:
40669ef23b6SZach Atkins        return f'No lines found in test source {true_csv}'
40712235d7fSJames Wright    if len(test_lines) != len(true_lines):
40812235d7fSJames Wright        return f'Number of lines in {test_csv} and {true_csv} do not match'
40912235d7fSJames Wright
41012235d7fSJames Wright    # Process commented lines
41112235d7fSJames Wright    uncommented_lines: List[int] = []
41212235d7fSJames Wright    for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)):
41312235d7fSJames Wright        if test_line[0] == comment_str and true_line[0] == comment_str:
41412235d7fSJames Wright            if comment_func:
41512235d7fSJames Wright                output = comment_func(test_line, true_line)
41612235d7fSJames Wright                if output:
41712235d7fSJames Wright                    return output
41812235d7fSJames Wright        elif test_line[0] == comment_str and true_line[0] != comment_str:
41912235d7fSJames Wright            return f'Commented line found in {test_csv} at line {n} but not in {true_csv}'
42012235d7fSJames Wright        elif test_line[0] != comment_str and true_line[0] == comment_str:
42112235d7fSJames Wright            return f'Commented line found in {true_csv} at line {n} but not in {test_csv}'
42212235d7fSJames Wright        else:
42312235d7fSJames Wright            uncommented_lines.append(n)
42412235d7fSJames Wright
42512235d7fSJames Wright    # Remove commented lines
42612235d7fSJames Wright    test_lines = [test_lines[line] for line in uncommented_lines]
42712235d7fSJames Wright    true_lines = [true_lines[line] for line in uncommented_lines]
4281b16049aSZach Atkins
42969ef23b6SZach Atkins    test_reader: csv.DictReader = csv.DictReader(test_lines)
43069ef23b6SZach Atkins    true_reader: csv.DictReader = csv.DictReader(true_lines)
431*f36e7531SZach Atkins    if not test_reader.fieldnames:
432*f36e7531SZach Atkins        return f'No CSV columns found in test output {test_csv}'
433*f36e7531SZach Atkins    if not true_reader.fieldnames:
434*f36e7531SZach Atkins        return f'No CSV columns found in test source {true_csv}'
43569ef23b6SZach Atkins    if test_reader.fieldnames != true_reader.fieldnames:
4361b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
4371b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
4381b16049aSZach Atkins
43978cb100bSJames Wright    diff_lines: List[str] = list()
44069ef23b6SZach Atkins    for test_line, true_line in zip(test_reader, true_reader):
44169ef23b6SZach Atkins        for key in test_reader.fieldnames:
44269ef23b6SZach Atkins            # Check if the value is numeric
44369ef23b6SZach Atkins            try:
44469ef23b6SZach Atkins                true_val: float = float(true_line[key])
44569ef23b6SZach Atkins                test_val: float = float(test_line[key])
4461b16049aSZach Atkins                true_zero: bool = abs(true_val) < zero_tol
4471b16049aSZach Atkins                test_zero: bool = abs(test_val) < zero_tol
4481b16049aSZach Atkins                fail: bool = False
4491b16049aSZach Atkins                if true_zero:
4501b16049aSZach Atkins                    fail = not test_zero
4511b16049aSZach Atkins                else:
4521b16049aSZach Atkins                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
4531b16049aSZach Atkins                if fail:
45469ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
45569ef23b6SZach Atkins            except ValueError:
45669ef23b6SZach Atkins                if test_line[key] != true_line[key]:
45769ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
45869ef23b6SZach Atkins
4591b16049aSZach Atkins    return '\n'.join(diff_lines)
4601b16049aSZach Atkins
4611b16049aSZach Atkins
462*f36e7531SZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float) -> str:
4631b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
4641b16049aSZach Atkins
4651b16049aSZach Atkins    Args:
4661b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
4671b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
468*f36e7531SZach Atkins        cgns_tol (float): Tolerance for comparing floating-point values
4691b16049aSZach Atkins
4701b16049aSZach Atkins    Returns:
4711b16049aSZach Atkins        str: Diff output between result and expected CGNS files
4721b16049aSZach Atkins    """
4731b16049aSZach Atkins    my_env: dict = os.environ.copy()
4741b16049aSZach Atkins
47583ebc4c4SJeremy L Thompson    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)]
4761b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
4771b16049aSZach Atkins                          shell=True,
4781b16049aSZach Atkins                          stdout=subprocess.PIPE,
4791b16049aSZach Atkins                          stderr=subprocess.PIPE,
4801b16049aSZach Atkins                          env=my_env)
4811b16049aSZach Atkins
4821b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
4831b16049aSZach Atkins
4841b16049aSZach Atkins
485*f36e7531SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str:
486*f36e7531SZach Atkins    """Compare ASCII results against an expected ASCII file
487*f36e7531SZach Atkins
488*f36e7531SZach Atkins    Args:
489*f36e7531SZach Atkins        test_file (Path): Path to output ASCII file
490*f36e7531SZach Atkins        true_file (Path): Path to expected ASCII file
491*f36e7531SZach Atkins
492*f36e7531SZach Atkins    Returns:
493*f36e7531SZach Atkins        str: Diff output between result and expected ASCII files
494*f36e7531SZach Atkins    """
495*f36e7531SZach Atkins    tmp_backend: str = backend.replace('/', '-')
496*f36e7531SZach Atkins    true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend)
497*f36e7531SZach Atkins    diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True),
498*f36e7531SZach Atkins                                     true_str.splitlines(keepends=True),
499*f36e7531SZach Atkins                                     fromfile=str(test_file),
500*f36e7531SZach Atkins                                     tofile=str(true_file)))
501*f36e7531SZach Atkins    return ''.join(diff)
502*f36e7531SZach Atkins
503*f36e7531SZach Atkins
504e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
505*f36e7531SZach Atkins                            backend: str, test: str, index: int, verbose: bool) -> str:
506e17e35bbSJames Wright    output_str = ''
507e17e35bbSJames Wright    if mode is RunMode.TAP:
508e17e35bbSJames Wright        # print incremental output if TAP mode
509e17e35bbSJames Wright        if test_case.is_skipped():
510e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
511e17e35bbSJames Wright        elif test_case.is_failure() or test_case.is_error():
512*f36e7531SZach Atkins            output_str += f'    not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
513e17e35bbSJames Wright        else:
514*f36e7531SZach Atkins            output_str += f'    ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
515*f36e7531SZach Atkins        if test_case.is_failure() or test_case.is_error() or verbose:
516e17e35bbSJames Wright            output_str += f'      ---\n'
517e17e35bbSJames Wright            if spec.only:
518e17e35bbSJames Wright                output_str += f'      only: {",".join(spec.only)}\n'
519e17e35bbSJames Wright            output_str += f'      args: {test_case.args}\n'
520*f36e7531SZach Atkins            if spec.csv_ztol > 0:
521*f36e7531SZach Atkins                output_str += f'      csv_ztol: {spec.csv_ztol}\n'
522*f36e7531SZach Atkins            if spec.csv_rtol > 0:
523*f36e7531SZach Atkins                output_str += f'      csv_rtol: {spec.csv_rtol}\n'
524*f36e7531SZach Atkins            if spec.cgns_tol > 0:
525*f36e7531SZach Atkins                output_str += f'      cgns_tol: {spec.cgns_tol}\n'
526*f36e7531SZach Atkins            for k, v in spec.key_values.items():
527*f36e7531SZach Atkins                output_str += f'      {k}: {v}\n'
528e17e35bbSJames Wright            if test_case.is_error():
529e17e35bbSJames Wright                output_str += f'      error: {test_case.errors[0]["message"]}\n'
530e17e35bbSJames Wright            if test_case.is_failure():
531*f36e7531SZach Atkins                output_str += f'      failures:\n'
532e17e35bbSJames Wright                for i, failure in enumerate(test_case.failures):
533*f36e7531SZach Atkins                    output_str += f'        -\n'
534e17e35bbSJames Wright                    output_str += f'          message: {failure["message"]}\n'
535e17e35bbSJames Wright                    if failure["output"]:
536e17e35bbSJames Wright                        out = failure["output"].strip().replace('\n', '\n            ')
537e17e35bbSJames Wright                        output_str += f'          output: |\n            {out}\n'
538e17e35bbSJames Wright            output_str += f'      ...\n'
539e17e35bbSJames Wright    else:
540e17e35bbSJames Wright        # print error or failure information if JUNIT mode
541e17e35bbSJames Wright        if test_case.is_error() or test_case.is_failure():
542e17e35bbSJames Wright            output_str += f'Test: {test} {spec.name}\n'
543e17e35bbSJames Wright            output_str += f'  $ {test_case.args}\n'
544e17e35bbSJames Wright            if test_case.is_error():
545e17e35bbSJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
546e17e35bbSJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
547e17e35bbSJames Wright            if test_case.is_failure():
548e17e35bbSJames Wright                for failure in test_case.failures:
549e17e35bbSJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
550e17e35bbSJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
551e17e35bbSJames Wright    return output_str
552e17e35bbSJames Wright
553e17e35bbSJames Wright
554*f36e7531SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path:
555*f36e7531SZach Atkins    """Attach a file to a test case
556*f36e7531SZach Atkins
557*f36e7531SZach Atkins    Args:
558*f36e7531SZach Atkins        test_case (TestCase): Test case to attach the file to
559*f36e7531SZach Atkins        file (Path): Path to the file to attach
560*f36e7531SZach Atkins    """
561*f36e7531SZach Atkins    save_path: Path = suite_spec.test_failure_artifacts_path / file.name
562*f36e7531SZach Atkins    shutil.copyfile(file, save_path)
563*f36e7531SZach Atkins    return save_path
564*f36e7531SZach Atkins
565*f36e7531SZach Atkins
56619868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str,
567*f36e7531SZach Atkins             mode: RunMode, nproc: int, suite_spec: SuiteSpec, verbose: bool = False) -> TestCase:
56819868e18SZach Atkins    """Run a single test case and backend combination
5691b16049aSZach Atkins
5701b16049aSZach Atkins    Args:
5718938a869SZach Atkins        index (int): Index of backend for current spec
57219868e18SZach Atkins        test (str): Path to test
57319868e18SZach Atkins        spec (TestSpec): Specification of test case
57419868e18SZach Atkins        backend (str): CEED backend
57519868e18SZach Atkins        mode (RunMode): Output mode
57619868e18SZach Atkins        nproc (int): Number of MPI processes to use when running test case
57719868e18SZach Atkins        suite_spec (SuiteSpec): Specification of test suite
578*f36e7531SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
5791b16049aSZach Atkins
5801b16049aSZach Atkins    Returns:
58119868e18SZach Atkins        TestCase: Test case result
5821b16049aSZach Atkins    """
5831b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
5848938a869SZach Atkins    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
5851b16049aSZach Atkins
5861b16049aSZach Atkins    if '{ceed_resource}' in run_args:
58719868e18SZach Atkins        run_args[run_args.index('{ceed_resource}')] = backend
5888938a869SZach Atkins    for i, arg in enumerate(run_args):
5898938a869SZach Atkins        if '{ceed_resource}' in arg:
5908938a869SZach Atkins            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
5911b16049aSZach Atkins    if '{nproc}' in run_args:
5921b16049aSZach Atkins        run_args[run_args.index('{nproc}')] = f'{nproc}'
5931b16049aSZach Atkins    elif nproc > 1 and source_path.suffix != '.py':
5941b16049aSZach Atkins        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
5951b16049aSZach Atkins
5961b16049aSZach Atkins    # run test
597*f36e7531SZach Atkins    skip_reason: Optional[str] = suite_spec.check_pre_skip(test, spec, backend, nproc)
5981b16049aSZach Atkins    if skip_reason:
59919868e18SZach Atkins        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6001b16049aSZach Atkins                                       elapsed_sec=0,
6011b16049aSZach Atkins                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
6021b16049aSZach Atkins                                       stdout='',
6038938a869SZach Atkins                                       stderr='',
6048938a869SZach Atkins                                       category=spec.name,)
6051b16049aSZach Atkins        test_case.add_skipped_info(skip_reason)
6061b16049aSZach Atkins    else:
6071b16049aSZach Atkins        start: float = time.time()
6081b16049aSZach Atkins        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
6091b16049aSZach Atkins                              shell=True,
6101b16049aSZach Atkins                              stdout=subprocess.PIPE,
6111b16049aSZach Atkins                              stderr=subprocess.PIPE,
6121b16049aSZach Atkins                              env=my_env)
6131b16049aSZach Atkins
61419868e18SZach Atkins        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6151b16049aSZach Atkins                             classname=source_path.parent,
6161b16049aSZach Atkins                             elapsed_sec=time.time() - start,
6171b16049aSZach Atkins                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
6181b16049aSZach Atkins                             stdout=proc.stdout.decode('utf-8'),
6191b16049aSZach Atkins                             stderr=proc.stderr.decode('utf-8'),
6208938a869SZach Atkins                             allow_multiple_subelements=True,
6218938a869SZach Atkins                             category=spec.name,)
62278cb100bSJames Wright        ref_csvs: List[Path] = []
623*f36e7531SZach Atkins        ref_ascii: List[Path] = []
6248938a869SZach Atkins        output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg]
62597fab443SJeremy L Thompson        if output_files:
626*f36e7531SZach Atkins            ref_csvs = [suite_spec.get_output_path(test, file.split(':')[1])
627*f36e7531SZach Atkins                        for file in output_files if file.endswith('.csv')]
628*f36e7531SZach Atkins            ref_ascii = [suite_spec.get_output_path(test, file.split(':')[1])
629*f36e7531SZach Atkins                         for file in output_files if not file.endswith('.csv')]
63078cb100bSJames Wright        ref_cgns: List[Path] = []
6318938a869SZach Atkins        output_files = [arg for arg in run_args if 'cgns:' in arg]
63297fab443SJeremy L Thompson        if output_files:
6331b16049aSZach Atkins            ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
6341b16049aSZach Atkins        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
635*f36e7531SZach Atkins        suite_spec.post_test_hook(test, spec, backend)
6361b16049aSZach Atkins
6371b16049aSZach Atkins    # check allowed failures
6381b16049aSZach Atkins    if not test_case.is_skipped() and test_case.stderr:
639*f36e7531SZach Atkins        skip_reason: Optional[str] = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
6401b16049aSZach Atkins        if skip_reason:
6411b16049aSZach Atkins            test_case.add_skipped_info(skip_reason)
6421b16049aSZach Atkins
6431b16049aSZach Atkins    # check required failures
6441b16049aSZach Atkins    if not test_case.is_skipped():
6452fee3251SSebastian Grimberg        required_message, did_fail = suite_spec.check_required_failure(
64619868e18SZach Atkins            test, spec, backend, test_case.stderr)
6471b16049aSZach Atkins        if required_message and did_fail:
6481b16049aSZach Atkins            test_case.status = f'fails with required: {required_message}'
6491b16049aSZach Atkins        elif required_message:
6501b16049aSZach Atkins            test_case.add_failure_info(f'required failure missing: {required_message}')
6511b16049aSZach Atkins
6521b16049aSZach Atkins    # classify other results
6531b16049aSZach Atkins    if not test_case.is_skipped() and not test_case.status:
6541b16049aSZach Atkins        if test_case.stderr:
6551b16049aSZach Atkins            test_case.add_failure_info('stderr', test_case.stderr)
6561b16049aSZach Atkins        if proc.returncode != 0:
6571b16049aSZach Atkins            test_case.add_error_info(f'returncode = {proc.returncode}')
6581b16049aSZach Atkins        if ref_stdout.is_file():
6591b16049aSZach Atkins            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
6601b16049aSZach Atkins                                             test_case.stdout.splitlines(keepends=True),
6611b16049aSZach Atkins                                             fromfile=str(ref_stdout),
6621b16049aSZach Atkins                                             tofile='New'))
6631b16049aSZach Atkins            if diff:
6641b16049aSZach Atkins                test_case.add_failure_info('stdout', output=''.join(diff))
6651b16049aSZach Atkins        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
6661b16049aSZach Atkins            test_case.add_failure_info('stdout', output=test_case.stdout)
6671b16049aSZach Atkins        # expected CSV output
6681b16049aSZach Atkins        for ref_csv in ref_csvs:
6698938a869SZach Atkins            csv_name = ref_csv.name
670*f36e7531SZach Atkins            out_file = Path.cwd() / csv_name
6718938a869SZach Atkins            if not ref_csv.is_file():
6728938a869SZach Atkins                # remove _{ceed_backend} from path name
6738938a869SZach Atkins                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
6741b16049aSZach Atkins            if not ref_csv.is_file():
6751b16049aSZach Atkins                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
676*f36e7531SZach Atkins            elif not out_file.is_file():
677*f36e7531SZach Atkins                test_case.add_failure_info('csv', output=f'{out_file} not found')
6781b16049aSZach Atkins            else:
679*f36e7531SZach Atkins                csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol
680*f36e7531SZach Atkins                csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol
681*f36e7531SZach Atkins                diff = diff_csv(out_file, ref_csv, zero_tol=csv_ztol, rel_tol=csv_rtol)
6821b16049aSZach Atkins                if diff:
683*f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / csv_name
684*f36e7531SZach Atkins                    shutil.move(out_file, save_path)
685*f36e7531SZach Atkins                    test_case.add_failure_info(f'csv: {save_path}', output=diff)
6861b16049aSZach Atkins                else:
687*f36e7531SZach Atkins                    out_file.unlink()
6881b16049aSZach Atkins        # expected CGNS output
6891b16049aSZach Atkins        for ref_cgn in ref_cgns:
6908938a869SZach Atkins            cgn_name = ref_cgn.name
691*f36e7531SZach Atkins            out_file = Path.cwd() / cgn_name
6928938a869SZach Atkins            if not ref_cgn.is_file():
6938938a869SZach Atkins                # remove _{ceed_backend} from path name
6948938a869SZach Atkins                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
6951b16049aSZach Atkins            if not ref_cgn.is_file():
6961b16049aSZach Atkins                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
697*f36e7531SZach Atkins            elif not out_file.is_file():
698*f36e7531SZach Atkins                test_case.add_failure_info('cgns', output=f'{out_file} not found')
6991b16049aSZach Atkins            else:
700*f36e7531SZach Atkins                cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol
701*f36e7531SZach Atkins                diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol)
7021b16049aSZach Atkins                if diff:
703*f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name
704*f36e7531SZach Atkins                    shutil.move(out_file, save_path)
705*f36e7531SZach Atkins                    test_case.add_failure_info(f'cgns: {save_path}', output=diff)
7061b16049aSZach Atkins                else:
707*f36e7531SZach Atkins                    out_file.unlink()
708*f36e7531SZach Atkins        # expected ASCII output
709*f36e7531SZach Atkins        for ref_file in ref_ascii:
710*f36e7531SZach Atkins            ref_name = ref_file.name
711*f36e7531SZach Atkins            out_file = Path.cwd() / ref_name
712*f36e7531SZach Atkins            if not ref_file.is_file():
713*f36e7531SZach Atkins                # remove _{ceed_backend} from path name
714*f36e7531SZach Atkins                ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix)
715*f36e7531SZach Atkins            if not ref_file.is_file():
716*f36e7531SZach Atkins                test_case.add_failure_info('ascii', output=f'{ref_file} not found')
717*f36e7531SZach Atkins            elif not out_file.is_file():
718*f36e7531SZach Atkins                test_case.add_failure_info('ascii', output=f'{out_file} not found')
719*f36e7531SZach Atkins            else:
720*f36e7531SZach Atkins                diff = diff_ascii(out_file, ref_file, backend)
721*f36e7531SZach Atkins                if diff:
722*f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / ref_name
723*f36e7531SZach Atkins                    shutil.move(out_file, save_path)
724*f36e7531SZach Atkins                    test_case.add_failure_info(f'ascii: {save_path}', output=diff)
725*f36e7531SZach Atkins                else:
726*f36e7531SZach Atkins                    out_file.unlink()
7271b16049aSZach Atkins
7281b16049aSZach Atkins    # store result
7291b16049aSZach Atkins    test_case.args = ' '.join(str(arg) for arg in run_args)
730*f36e7531SZach Atkins    output_str = test_case_output_string(test_case, spec, mode, backend, test, index, verbose)
73119868e18SZach Atkins
73219868e18SZach Atkins    return test_case, output_str
73319868e18SZach Atkins
73419868e18SZach Atkins
73519868e18SZach Atkinsdef init_process():
73619868e18SZach Atkins    """Initialize multiprocessing process"""
73719868e18SZach Atkins    # set up error handler
73819868e18SZach Atkins    global my_env
73919868e18SZach Atkins    my_env = os.environ.copy()
74019868e18SZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
74119868e18SZach Atkins
74219868e18SZach Atkins
74378cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
744*f36e7531SZach Atkins              suite_spec: SuiteSpec, pool_size: int = 1, search: str = ".*", verbose: bool = False) -> TestSuite:
74519868e18SZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
74619868e18SZach Atkins
74719868e18SZach Atkins    Args:
74819868e18SZach Atkins        test (str): Name of test
7498938a869SZach Atkins        ceed_backends (List[str]): List of libCEED backends
75019868e18SZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
75119868e18SZach Atkins        nproc (int): Number of MPI processes to use when running each test case
75219868e18SZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
75319868e18SZach Atkins        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
754*f36e7531SZach Atkins        search (str, optional): Regular expression used to match tests. Defaults to ".*".
755*f36e7531SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
75619868e18SZach Atkins
75719868e18SZach Atkins    Returns:
75819868e18SZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
75919868e18SZach Atkins    """
760*f36e7531SZach Atkins    test_specs: List[TestSpec] = [
761*f36e7531SZach Atkins        t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE)
762*f36e7531SZach Atkins    ]
763*f36e7531SZach Atkins    suite_spec.test_failure_artifacts_path.mkdir(parents=True, exist_ok=True)
76419868e18SZach Atkins    if mode is RunMode.TAP:
7658938a869SZach Atkins        print('TAP version 13')
7668938a869SZach Atkins        print(f'1..{len(test_specs)}')
76719868e18SZach Atkins
76819868e18SZach Atkins    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
769*f36e7531SZach Atkins        async_outputs: List[List[mp.pool.AsyncResult]] = [
770*f36e7531SZach Atkins            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec, verbose))
7718938a869SZach Atkins             for (i, backend) in enumerate(ceed_backends, start=1)]
7728938a869SZach Atkins            for spec in test_specs
7738938a869SZach Atkins        ]
77419868e18SZach Atkins
77519868e18SZach Atkins        test_cases = []
7768938a869SZach Atkins        for (i, subtest) in enumerate(async_outputs, start=1):
7778938a869SZach Atkins            is_new_subtest = True
7788938a869SZach Atkins            subtest_ok = True
7798938a869SZach Atkins            for async_output in subtest:
78019868e18SZach Atkins                test_case, print_output = async_output.get()
78119868e18SZach Atkins                test_cases.append(test_case)
7828938a869SZach Atkins                if is_new_subtest and mode == RunMode.TAP:
7838938a869SZach Atkins                    is_new_subtest = False
7848938a869SZach Atkins                    print(f'# Subtest: {test_case.category}')
7858938a869SZach Atkins                    print(f'    1..{len(ceed_backends)}')
78619868e18SZach Atkins                print(print_output, end='')
7878938a869SZach Atkins                if test_case.is_failure() or test_case.is_error():
7888938a869SZach Atkins                    subtest_ok = False
7898938a869SZach Atkins            if mode == RunMode.TAP:
7908938a869SZach Atkins                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
7911b16049aSZach Atkins
7921b16049aSZach Atkins    return TestSuite(test, test_cases)
7931b16049aSZach Atkins
7941b16049aSZach Atkins
7951b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
7961b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
7971b16049aSZach Atkins
7981b16049aSZach Atkins    Args:
7991b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
8001b16049aSZach Atkins        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
8011b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
8021b16049aSZach Atkins    """
803*f36e7531SZach Atkins    output_file = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
8041b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
8051b16049aSZach Atkins
8061b16049aSZach Atkins
8071b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
8081b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
8091b16049aSZach Atkins
8101b16049aSZach Atkins    Args:
8111b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
8121b16049aSZach Atkins
8131b16049aSZach Atkins    Returns:
8141b16049aSZach Atkins        bool: True if any test cases failed
8151b16049aSZach Atkins    """
8161b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
817