xref: /libCEED/tests/junit_common.py (revision 7b1ec8807c067184328a454e68b003059cbc1258)
11b16049aSZach Atkinsfrom abc import ABC, abstractmethod
2f36e7531SZach Atkinsfrom collections.abc import Iterable
31b16049aSZach Atkinsimport argparse
469ef23b6SZach Atkinsimport csv
5f36e7531SZach 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
16f36e7531SZach Atkinsfrom typing import Optional, Tuple, List, Dict, Callable, Iterable, get_origin
17f36e7531SZach 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
23f36e7531SZach Atkinsclass ParseError(RuntimeError):
24f36e7531SZach Atkins    """A custom exception for failed parsing."""
25f36e7531SZach Atkins
26f36e7531SZach Atkins    def __init__(self, message):
27f36e7531SZach Atkins        super().__init__(message)
28f36e7531SZach Atkins
29f36e7531SZach 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):
34f36e7531SZach Atkins        if not issubclass(type, Enum):
35f36e7531SZach Atkins            raise ValueError(f"{type} must be an Enum")
361b16049aSZach Atkins        # store provided enum type
371b16049aSZach Atkins        self.enum_type = type
38f36e7531SZach Atkins        if isinstance(default, self.enum_type):
39f36e7531SZach Atkins            pass
40f36e7531SZach Atkins        elif isinstance(default, str):
411b16049aSZach Atkins            default = self.enum_type(default.lower())
42f36e7531SZach Atkins        elif isinstance(default, Iterable):
431b16049aSZach Atkins            default = [self.enum_type(v.lower()) for v in default]
44f36e7531SZach Atkins        else:
45f36e7531SZach 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):
50f36e7531SZach Atkins        if isinstance(values, self.enum_type):
51f36e7531SZach Atkins            pass
52f36e7531SZach Atkins        elif isinstance(values, str):
531b16049aSZach Atkins            values = self.enum_type(values.lower())
54f36e7531SZach Atkins        elif isinstance(values, Iterable):
551b16049aSZach Atkins            values = [self.enum_type(v.lower()) for v in values]
56f36e7531SZach Atkins        else:
57f36e7531SZach 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"""
64f36e7531SZach Atkins    name: str = field(default_factory=str)
65f36e7531SZach Atkins    csv_rtol: float = -1
66f36e7531SZach Atkins    csv_ztol: float = -1
67f36e7531SZach Atkins    cgns_tol: float = -1
688938a869SZach Atkins    only: List = field(default_factory=list)
698938a869SZach Atkins    args: List = field(default_factory=list)
70f36e7531SZach Atkins    key_values: Dict = field(default_factory=dict)
711b16049aSZach Atkins
721b16049aSZach Atkins
73f36e7531SZach Atkinsclass RunMode(Enum):
741b16049aSZach Atkins    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
75f36e7531SZach Atkins    TAP = 'tap'
76f36e7531SZach Atkins    JUNIT = 'junit'
77f36e7531SZach Atkins
78f36e7531SZach Atkins    def __str__(self):
79f36e7531SZach Atkins        return self.value
80f36e7531SZach Atkins
81f36e7531SZach Atkins    def __repr__(self):
82f36e7531SZach 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
125f36e7531SZach Atkins    def test_failure_artifacts_path(self) -> Path:
126f36e7531SZach Atkins        """Path to test failure artifacts"""
127f36e7531SZach Atkins        return Path('build') / 'test_failure_artifacts'
128f36e7531SZach Atkins
129f36e7531SZach 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
139f36e7531SZach Atkins    def csv_ztol(self):
14012235d7fSJames Wright        """Keyword arguments to be passed to diff_csv()"""
141f36e7531SZach Atkins        return getattr(self, '_csv_ztol', 3e-10)
14212235d7fSJames Wright
143f36e7531SZach Atkins    @csv_ztol.setter
144f36e7531SZach Atkins    def csv_ztol(self, val):
145f36e7531SZach Atkins        self._csv_ztol = val
14612235d7fSJames Wright
147f36e7531SZach Atkins    @property
148f36e7531SZach Atkins    def csv_rtol(self):
149f36e7531SZach Atkins        """Keyword arguments to be passed to diff_csv()"""
150f36e7531SZach Atkins        return getattr(self, '_csv_rtol', 1e-6)
151f36e7531SZach Atkins
152f36e7531SZach Atkins    @csv_rtol.setter
153f36e7531SZach Atkins    def csv_rtol(self, val):
154f36e7531SZach Atkins        self._csv_rtol = val
155f36e7531SZach Atkins
156f36e7531SZach 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
260f36e7531SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]:
261f36e7531SZach Atkins    """Find the start and end positions of the first outer paired delimeters
262f36e7531SZach Atkins
263f36e7531SZach Atkins    Args:
264f36e7531SZach Atkins        line (str): Line to search
265f36e7531SZach Atkins        open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('.
266f36e7531SZach Atkins        close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'.
267f36e7531SZach Atkins
268f36e7531SZach Atkins    Raises:
269f36e7531SZach Atkins        RuntimeError: If open or close is not a single character
270f36e7531SZach Atkins        RuntimeError: If open and close are the same characters
271f36e7531SZach Atkins
272f36e7531SZach Atkins    Returns:
273f36e7531SZach Atkins        Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start.
274f36e7531SZach Atkins    """
275f36e7531SZach Atkins    if len(open) != 1 or len(close) != 1:
276f36e7531SZach Atkins        raise RuntimeError("`open` and `close` must be single characters")
277f36e7531SZach Atkins    if open == close:
278f36e7531SZach Atkins        raise RuntimeError("`open` and `close` must be different characters")
279f36e7531SZach Atkins    start: int = line.find(open)
280f36e7531SZach Atkins    if start < 0:
281f36e7531SZach Atkins        return -1, -1
282f36e7531SZach Atkins    count: int = 1
283f36e7531SZach Atkins    for i in range(start + 1, len(line)):
284f36e7531SZach Atkins        if line[i] == open:
285f36e7531SZach Atkins            count += 1
286f36e7531SZach Atkins        if line[i] == close:
287f36e7531SZach Atkins            count -= 1
288f36e7531SZach Atkins            if count == 0:
289f36e7531SZach Atkins                return start, i
290f36e7531SZach Atkins    return start, -1
291f36e7531SZach Atkins
292f36e7531SZach Atkins
293*7b1ec880SZach Atkinsdef parse_test_line(line: str, fallback_name: 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    """
302f36e7531SZach Atkins    test_fields = fields(TestSpec)
303f36e7531SZach Atkins    field_names = [f.name for f in test_fields]
304f36e7531SZach Atkins    known: Dict = dict()
305f36e7531SZach Atkins    other: Dict = dict()
306f36e7531SZach Atkins    if line[0] == "(":
307f36e7531SZach Atkins        # have key/value pairs to parse
308f36e7531SZach Atkins        start, end = find_matching(line)
309f36e7531SZach Atkins        if end < start:
310f36e7531SZach Atkins            raise ParseError(f"Mismatched parentheses in TESTCASE: {line}")
311f36e7531SZach Atkins
312f36e7531SZach Atkins        keyvalues_str = line[start:end + 1]
313f36e7531SZach Atkins        keyvalues_pattern = re.compile(r'''
314f36e7531SZach Atkins            (?:\(\s*|\s*,\s*)   # start with open parentheses or comma, no capture
315f36e7531SZach Atkins            ([A-Za-z]+[\w\-]+)  # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1
316f36e7531SZach Atkins            \s*=\s*             # key is followed by = (whitespace ignored)
317f36e7531SZach Atkins            (?:                 # uncaptured group for OR
318f36e7531SZach Atkins              "((?:[^"]|\\")+)" #   match quoted value (any internal " must be escaped as \"); captured as Group 2
319f36e7531SZach Atkins            | ([^=]+)           #   OR match unquoted value (no equals signs allowed); captured as Group 3
320f36e7531SZach Atkins            )                   # end uncaptured group for OR
321f36e7531SZach Atkins            \s*(?=,|\))         # lookahead for either next comma or closing parentheses
322f36e7531SZach Atkins        ''', re.VERBOSE)
323f36e7531SZach Atkins
324f36e7531SZach Atkins        for match in re.finditer(keyvalues_pattern, keyvalues_str):
325f36e7531SZach Atkins            if not match:  # empty
326f36e7531SZach Atkins                continue
327f36e7531SZach Atkins            key = match.group(1)
328f36e7531SZach Atkins            value = match.group(2) if match.group(2) else match.group(3)
329f36e7531SZach Atkins            try:
330f36e7531SZach Atkins                index = field_names.index(key)
331f36e7531SZach Atkins                if key == "only":  # weird bc only is a list
332f36e7531SZach Atkins                    value = [constraint.strip() for constraint in value.split(',')]
333f36e7531SZach Atkins                try:
334f36e7531SZach Atkins                    # TODO: stop supporting python <=3.8
335f36e7531SZach Atkins                    known[key] = test_fields[index].type(value)  # type: ignore
336f36e7531SZach Atkins                except TypeError:
337f36e7531SZach Atkins                    # TODO: this is still liable to fail for complex types
338f36e7531SZach Atkins                    known[key] = get_origin(test_fields[index].type)(value)  # type: ignore
339f36e7531SZach Atkins            except ValueError:
340f36e7531SZach Atkins                other[key] = value
341f36e7531SZach Atkins
342f36e7531SZach Atkins        line = line[end + 1:]
343f36e7531SZach Atkins
344*7b1ec880SZach Atkins    if not 'name' in known.keys():
345*7b1ec880SZach Atkins        known['name'] = fallback_name
346*7b1ec880SZach Atkins
347f36e7531SZach Atkins    args_pattern = re.compile(r'''
348f36e7531SZach Atkins        \s+(            # remove leading space
349f36e7531SZach Atkins            (?:"[^"]+") # match quoted CLI option
350f36e7531SZach Atkins          | (?:[\S]+)   # match anything else that is space separated
351f36e7531SZach Atkins        )
352f36e7531SZach Atkins    ''', re.VERBOSE)
353f36e7531SZach Atkins    args: List[str] = re.findall(args_pattern, line)
354f36e7531SZach Atkins    for k, v in other.items():
355f36e7531SZach Atkins        print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}")
356f36e7531SZach Atkins    return TestSpec(**known, key_values=other, args=args)
3571b16049aSZach Atkins
3581b16049aSZach Atkins
35978cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
3601b16049aSZach Atkins    """Parse all test cases from a given source file
3611b16049aSZach Atkins
3621b16049aSZach Atkins    Args:
3631b16049aSZach Atkins        source_file (Path): Path to source file
3641b16049aSZach Atkins
3651b16049aSZach Atkins    Raises:
3661b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
3671b16049aSZach Atkins
3681b16049aSZach Atkins    Returns:
3698938a869SZach Atkins        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
3701b16049aSZach Atkins    """
3711b16049aSZach Atkins    comment_str: str = ''
3728c81f8b0SPeter Munch    if source_file.suffix in ['.c', '.cc', '.cpp']:
3731b16049aSZach Atkins        comment_str = '//'
3741b16049aSZach Atkins    elif source_file.suffix in ['.py']:
3751b16049aSZach Atkins        comment_str = '#'
3761b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
3771b16049aSZach Atkins        comment_str = 'C_'
3781b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
3791b16049aSZach Atkins        comment_str = '! '
3801b16049aSZach Atkins    else:
3811b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
3821b16049aSZach Atkins
383*7b1ec880SZach Atkins    return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS"), source_file.stem)
3841b16049aSZach Atkins            for line in source_file.read_text().splitlines()
385*7b1ec880SZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec(source_file.stem, args=['{ceed_resource}'])]
3861b16049aSZach Atkins
3871b16049aSZach Atkins
388f36e7531SZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float, rel_tol: float,
38912235d7fSJames Wright             comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str:
3901b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
3911b16049aSZach Atkins
3921b16049aSZach Atkins    Args:
3931b16049aSZach Atkins        test_csv (Path): Path to output CSV results
3941b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
395f36e7531SZach Atkins        zero_tol (float): Tolerance below which values are considered to be zero.
396f36e7531SZach Atkins        rel_tol (float): Relative tolerance for comparing non-zero values.
39712235d7fSJames Wright        comment_str (str, optional): String to denoting commented line
39812235d7fSJames Wright        comment_func (Callable, optional): Function to determine if test and true line are different
3991b16049aSZach Atkins
4001b16049aSZach Atkins    Returns:
4011b16049aSZach Atkins        str: Diff output between result and expected CSVs
4021b16049aSZach Atkins    """
40378cb100bSJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
40478cb100bSJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
40569ef23b6SZach Atkins    # Files should not be empty
40669ef23b6SZach Atkins    if len(test_lines) == 0:
40769ef23b6SZach Atkins        return f'No lines found in test output {test_csv}'
40869ef23b6SZach Atkins    if len(true_lines) == 0:
40969ef23b6SZach Atkins        return f'No lines found in test source {true_csv}'
41012235d7fSJames Wright    if len(test_lines) != len(true_lines):
41112235d7fSJames Wright        return f'Number of lines in {test_csv} and {true_csv} do not match'
41212235d7fSJames Wright
41312235d7fSJames Wright    # Process commented lines
41412235d7fSJames Wright    uncommented_lines: List[int] = []
41512235d7fSJames Wright    for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)):
41612235d7fSJames Wright        if test_line[0] == comment_str and true_line[0] == comment_str:
41712235d7fSJames Wright            if comment_func:
41812235d7fSJames Wright                output = comment_func(test_line, true_line)
41912235d7fSJames Wright                if output:
42012235d7fSJames Wright                    return output
42112235d7fSJames Wright        elif test_line[0] == comment_str and true_line[0] != comment_str:
42212235d7fSJames Wright            return f'Commented line found in {test_csv} at line {n} but not in {true_csv}'
42312235d7fSJames Wright        elif test_line[0] != comment_str and true_line[0] == comment_str:
42412235d7fSJames Wright            return f'Commented line found in {true_csv} at line {n} but not in {test_csv}'
42512235d7fSJames Wright        else:
42612235d7fSJames Wright            uncommented_lines.append(n)
42712235d7fSJames Wright
42812235d7fSJames Wright    # Remove commented lines
42912235d7fSJames Wright    test_lines = [test_lines[line] for line in uncommented_lines]
43012235d7fSJames Wright    true_lines = [true_lines[line] for line in uncommented_lines]
4311b16049aSZach Atkins
43269ef23b6SZach Atkins    test_reader: csv.DictReader = csv.DictReader(test_lines)
43369ef23b6SZach Atkins    true_reader: csv.DictReader = csv.DictReader(true_lines)
434f36e7531SZach Atkins    if not test_reader.fieldnames:
435f36e7531SZach Atkins        return f'No CSV columns found in test output {test_csv}'
436f36e7531SZach Atkins    if not true_reader.fieldnames:
437f36e7531SZach Atkins        return f'No CSV columns found in test source {true_csv}'
43869ef23b6SZach Atkins    if test_reader.fieldnames != true_reader.fieldnames:
4391b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
4401b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
4411b16049aSZach Atkins
44278cb100bSJames Wright    diff_lines: List[str] = list()
44369ef23b6SZach Atkins    for test_line, true_line in zip(test_reader, true_reader):
44469ef23b6SZach Atkins        for key in test_reader.fieldnames:
44569ef23b6SZach Atkins            # Check if the value is numeric
44669ef23b6SZach Atkins            try:
44769ef23b6SZach Atkins                true_val: float = float(true_line[key])
44869ef23b6SZach Atkins                test_val: float = float(test_line[key])
4491b16049aSZach Atkins                true_zero: bool = abs(true_val) < zero_tol
4501b16049aSZach Atkins                test_zero: bool = abs(test_val) < zero_tol
4511b16049aSZach Atkins                fail: bool = False
4521b16049aSZach Atkins                if true_zero:
4531b16049aSZach Atkins                    fail = not test_zero
4541b16049aSZach Atkins                else:
4551b16049aSZach Atkins                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
4561b16049aSZach Atkins                if fail:
45769ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
45869ef23b6SZach Atkins            except ValueError:
45969ef23b6SZach Atkins                if test_line[key] != true_line[key]:
46069ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
46169ef23b6SZach Atkins
4621b16049aSZach Atkins    return '\n'.join(diff_lines)
4631b16049aSZach Atkins
4641b16049aSZach Atkins
465f36e7531SZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float) -> str:
4661b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
4671b16049aSZach Atkins
4681b16049aSZach Atkins    Args:
4691b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
4701b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
471f36e7531SZach Atkins        cgns_tol (float): Tolerance for comparing floating-point values
4721b16049aSZach Atkins
4731b16049aSZach Atkins    Returns:
4741b16049aSZach Atkins        str: Diff output between result and expected CGNS files
4751b16049aSZach Atkins    """
4761b16049aSZach Atkins    my_env: dict = os.environ.copy()
4771b16049aSZach Atkins
47883ebc4c4SJeremy L Thompson    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)]
4791b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
4801b16049aSZach Atkins                          shell=True,
4811b16049aSZach Atkins                          stdout=subprocess.PIPE,
4821b16049aSZach Atkins                          stderr=subprocess.PIPE,
4831b16049aSZach Atkins                          env=my_env)
4841b16049aSZach Atkins
4851b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
4861b16049aSZach Atkins
4871b16049aSZach Atkins
488f36e7531SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str:
489f36e7531SZach Atkins    """Compare ASCII results against an expected ASCII file
490f36e7531SZach Atkins
491f36e7531SZach Atkins    Args:
492f36e7531SZach Atkins        test_file (Path): Path to output ASCII file
493f36e7531SZach Atkins        true_file (Path): Path to expected ASCII file
494f36e7531SZach Atkins
495f36e7531SZach Atkins    Returns:
496f36e7531SZach Atkins        str: Diff output between result and expected ASCII files
497f36e7531SZach Atkins    """
498f36e7531SZach Atkins    tmp_backend: str = backend.replace('/', '-')
499f36e7531SZach Atkins    true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend)
500f36e7531SZach Atkins    diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True),
501f36e7531SZach Atkins                                     true_str.splitlines(keepends=True),
502f36e7531SZach Atkins                                     fromfile=str(test_file),
503f36e7531SZach Atkins                                     tofile=str(true_file)))
504f36e7531SZach Atkins    return ''.join(diff)
505f36e7531SZach Atkins
506f36e7531SZach Atkins
507e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
508f36e7531SZach Atkins                            backend: str, test: str, index: int, verbose: bool) -> str:
509e17e35bbSJames Wright    output_str = ''
510e17e35bbSJames Wright    if mode is RunMode.TAP:
511e17e35bbSJames Wright        # print incremental output if TAP mode
512e17e35bbSJames Wright        if test_case.is_skipped():
513e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
514e17e35bbSJames Wright        elif test_case.is_failure() or test_case.is_error():
515f36e7531SZach Atkins            output_str += f'    not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
516e17e35bbSJames Wright        else:
517f36e7531SZach Atkins            output_str += f'    ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
518f36e7531SZach Atkins        if test_case.is_failure() or test_case.is_error() or verbose:
519e17e35bbSJames Wright            output_str += f'      ---\n'
520e17e35bbSJames Wright            if spec.only:
521e17e35bbSJames Wright                output_str += f'      only: {",".join(spec.only)}\n'
522e17e35bbSJames Wright            output_str += f'      args: {test_case.args}\n'
523f36e7531SZach Atkins            if spec.csv_ztol > 0:
524f36e7531SZach Atkins                output_str += f'      csv_ztol: {spec.csv_ztol}\n'
525f36e7531SZach Atkins            if spec.csv_rtol > 0:
526f36e7531SZach Atkins                output_str += f'      csv_rtol: {spec.csv_rtol}\n'
527f36e7531SZach Atkins            if spec.cgns_tol > 0:
528f36e7531SZach Atkins                output_str += f'      cgns_tol: {spec.cgns_tol}\n'
529f36e7531SZach Atkins            for k, v in spec.key_values.items():
530f36e7531SZach Atkins                output_str += f'      {k}: {v}\n'
531e17e35bbSJames Wright            if test_case.is_error():
532e17e35bbSJames Wright                output_str += f'      error: {test_case.errors[0]["message"]}\n'
533e17e35bbSJames Wright            if test_case.is_failure():
534f36e7531SZach Atkins                output_str += f'      failures:\n'
535e17e35bbSJames Wright                for i, failure in enumerate(test_case.failures):
536f36e7531SZach Atkins                    output_str += f'        -\n'
537e17e35bbSJames Wright                    output_str += f'          message: {failure["message"]}\n'
538e17e35bbSJames Wright                    if failure["output"]:
539e17e35bbSJames Wright                        out = failure["output"].strip().replace('\n', '\n            ')
540e17e35bbSJames Wright                        output_str += f'          output: |\n            {out}\n'
541e17e35bbSJames Wright            output_str += f'      ...\n'
542e17e35bbSJames Wright    else:
543e17e35bbSJames Wright        # print error or failure information if JUNIT mode
544e17e35bbSJames Wright        if test_case.is_error() or test_case.is_failure():
545e17e35bbSJames Wright            output_str += f'Test: {test} {spec.name}\n'
546e17e35bbSJames Wright            output_str += f'  $ {test_case.args}\n'
547e17e35bbSJames Wright            if test_case.is_error():
548e17e35bbSJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
549e17e35bbSJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
550e17e35bbSJames Wright            if test_case.is_failure():
551e17e35bbSJames Wright                for failure in test_case.failures:
552e17e35bbSJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
553e17e35bbSJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
554e17e35bbSJames Wright    return output_str
555e17e35bbSJames Wright
556e17e35bbSJames Wright
557f36e7531SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path:
558f36e7531SZach Atkins    """Attach a file to a test case
559f36e7531SZach Atkins
560f36e7531SZach Atkins    Args:
561f36e7531SZach Atkins        test_case (TestCase): Test case to attach the file to
562f36e7531SZach Atkins        file (Path): Path to the file to attach
563f36e7531SZach Atkins    """
564f36e7531SZach Atkins    save_path: Path = suite_spec.test_failure_artifacts_path / file.name
565f36e7531SZach Atkins    shutil.copyfile(file, save_path)
566f36e7531SZach Atkins    return save_path
567f36e7531SZach Atkins
568f36e7531SZach Atkins
56919868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str,
570f36e7531SZach Atkins             mode: RunMode, nproc: int, suite_spec: SuiteSpec, verbose: bool = False) -> TestCase:
57119868e18SZach Atkins    """Run a single test case and backend combination
5721b16049aSZach Atkins
5731b16049aSZach Atkins    Args:
5748938a869SZach Atkins        index (int): Index of backend for current spec
57519868e18SZach Atkins        test (str): Path to test
57619868e18SZach Atkins        spec (TestSpec): Specification of test case
57719868e18SZach Atkins        backend (str): CEED backend
57819868e18SZach Atkins        mode (RunMode): Output mode
57919868e18SZach Atkins        nproc (int): Number of MPI processes to use when running test case
58019868e18SZach Atkins        suite_spec (SuiteSpec): Specification of test suite
581f36e7531SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
5821b16049aSZach Atkins
5831b16049aSZach Atkins    Returns:
58419868e18SZach Atkins        TestCase: Test case result
5851b16049aSZach Atkins    """
5861b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
5878938a869SZach Atkins    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
5881b16049aSZach Atkins
5891b16049aSZach Atkins    if '{ceed_resource}' in run_args:
59019868e18SZach Atkins        run_args[run_args.index('{ceed_resource}')] = backend
5918938a869SZach Atkins    for i, arg in enumerate(run_args):
5928938a869SZach Atkins        if '{ceed_resource}' in arg:
5938938a869SZach Atkins            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
5941b16049aSZach Atkins    if '{nproc}' in run_args:
5951b16049aSZach Atkins        run_args[run_args.index('{nproc}')] = f'{nproc}'
5961b16049aSZach Atkins    elif nproc > 1 and source_path.suffix != '.py':
5971b16049aSZach Atkins        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
5981b16049aSZach Atkins
5991b16049aSZach Atkins    # run test
600f36e7531SZach Atkins    skip_reason: Optional[str] = suite_spec.check_pre_skip(test, spec, backend, nproc)
6011b16049aSZach Atkins    if skip_reason:
60219868e18SZach Atkins        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6031b16049aSZach Atkins                                       elapsed_sec=0,
6041b16049aSZach Atkins                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
6051b16049aSZach Atkins                                       stdout='',
6068938a869SZach Atkins                                       stderr='',
6078938a869SZach Atkins                                       category=spec.name,)
6081b16049aSZach Atkins        test_case.add_skipped_info(skip_reason)
6091b16049aSZach Atkins    else:
6101b16049aSZach Atkins        start: float = time.time()
6111b16049aSZach Atkins        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
6121b16049aSZach Atkins                              shell=True,
6131b16049aSZach Atkins                              stdout=subprocess.PIPE,
6141b16049aSZach Atkins                              stderr=subprocess.PIPE,
6151b16049aSZach Atkins                              env=my_env)
6161b16049aSZach Atkins
61719868e18SZach Atkins        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6181b16049aSZach Atkins                             classname=source_path.parent,
6191b16049aSZach Atkins                             elapsed_sec=time.time() - start,
6201b16049aSZach Atkins                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
6211b16049aSZach Atkins                             stdout=proc.stdout.decode('utf-8'),
6221b16049aSZach Atkins                             stderr=proc.stderr.decode('utf-8'),
6238938a869SZach Atkins                             allow_multiple_subelements=True,
6248938a869SZach Atkins                             category=spec.name,)
62578cb100bSJames Wright        ref_csvs: List[Path] = []
626f36e7531SZach Atkins        ref_ascii: List[Path] = []
6278938a869SZach Atkins        output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg]
62897fab443SJeremy L Thompson        if output_files:
629f36e7531SZach Atkins            ref_csvs = [suite_spec.get_output_path(test, file.split(':')[1])
630f36e7531SZach Atkins                        for file in output_files if file.endswith('.csv')]
631f36e7531SZach Atkins            ref_ascii = [suite_spec.get_output_path(test, file.split(':')[1])
632f36e7531SZach Atkins                         for file in output_files if not file.endswith('.csv')]
63378cb100bSJames Wright        ref_cgns: List[Path] = []
6348938a869SZach Atkins        output_files = [arg for arg in run_args if 'cgns:' in arg]
63597fab443SJeremy L Thompson        if output_files:
6361b16049aSZach Atkins            ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
6371b16049aSZach Atkins        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
638f36e7531SZach Atkins        suite_spec.post_test_hook(test, spec, backend)
6391b16049aSZach Atkins
6401b16049aSZach Atkins    # check allowed failures
6411b16049aSZach Atkins    if not test_case.is_skipped() and test_case.stderr:
642f36e7531SZach Atkins        skip_reason: Optional[str] = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
6431b16049aSZach Atkins        if skip_reason:
6441b16049aSZach Atkins            test_case.add_skipped_info(skip_reason)
6451b16049aSZach Atkins
6461b16049aSZach Atkins    # check required failures
6471b16049aSZach Atkins    if not test_case.is_skipped():
6482fee3251SSebastian Grimberg        required_message, did_fail = suite_spec.check_required_failure(
64919868e18SZach Atkins            test, spec, backend, test_case.stderr)
6501b16049aSZach Atkins        if required_message and did_fail:
6511b16049aSZach Atkins            test_case.status = f'fails with required: {required_message}'
6521b16049aSZach Atkins        elif required_message:
6531b16049aSZach Atkins            test_case.add_failure_info(f'required failure missing: {required_message}')
6541b16049aSZach Atkins
6551b16049aSZach Atkins    # classify other results
6561b16049aSZach Atkins    if not test_case.is_skipped() and not test_case.status:
6571b16049aSZach Atkins        if test_case.stderr:
6581b16049aSZach Atkins            test_case.add_failure_info('stderr', test_case.stderr)
6591b16049aSZach Atkins        if proc.returncode != 0:
6601b16049aSZach Atkins            test_case.add_error_info(f'returncode = {proc.returncode}')
6611b16049aSZach Atkins        if ref_stdout.is_file():
6621b16049aSZach Atkins            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
6631b16049aSZach Atkins                                             test_case.stdout.splitlines(keepends=True),
6641b16049aSZach Atkins                                             fromfile=str(ref_stdout),
6651b16049aSZach Atkins                                             tofile='New'))
6661b16049aSZach Atkins            if diff:
6671b16049aSZach Atkins                test_case.add_failure_info('stdout', output=''.join(diff))
6681b16049aSZach Atkins        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
6691b16049aSZach Atkins            test_case.add_failure_info('stdout', output=test_case.stdout)
6701b16049aSZach Atkins        # expected CSV output
6711b16049aSZach Atkins        for ref_csv in ref_csvs:
6728938a869SZach Atkins            csv_name = ref_csv.name
673f36e7531SZach Atkins            out_file = Path.cwd() / csv_name
6748938a869SZach Atkins            if not ref_csv.is_file():
6758938a869SZach Atkins                # remove _{ceed_backend} from path name
6768938a869SZach Atkins                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
6771b16049aSZach Atkins            if not ref_csv.is_file():
6781b16049aSZach Atkins                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
679f36e7531SZach Atkins            elif not out_file.is_file():
680f36e7531SZach Atkins                test_case.add_failure_info('csv', output=f'{out_file} not found')
6811b16049aSZach Atkins            else:
682f36e7531SZach Atkins                csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol
683f36e7531SZach Atkins                csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol
684f36e7531SZach Atkins                diff = diff_csv(out_file, ref_csv, zero_tol=csv_ztol, rel_tol=csv_rtol)
6851b16049aSZach Atkins                if diff:
686f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / csv_name
687f36e7531SZach Atkins                    shutil.move(out_file, save_path)
688f36e7531SZach Atkins                    test_case.add_failure_info(f'csv: {save_path}', output=diff)
6891b16049aSZach Atkins                else:
690f36e7531SZach Atkins                    out_file.unlink()
6911b16049aSZach Atkins        # expected CGNS output
6921b16049aSZach Atkins        for ref_cgn in ref_cgns:
6938938a869SZach Atkins            cgn_name = ref_cgn.name
694f36e7531SZach Atkins            out_file = Path.cwd() / cgn_name
6958938a869SZach Atkins            if not ref_cgn.is_file():
6968938a869SZach Atkins                # remove _{ceed_backend} from path name
6978938a869SZach Atkins                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
6981b16049aSZach Atkins            if not ref_cgn.is_file():
6991b16049aSZach Atkins                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
700f36e7531SZach Atkins            elif not out_file.is_file():
701f36e7531SZach Atkins                test_case.add_failure_info('cgns', output=f'{out_file} not found')
7021b16049aSZach Atkins            else:
703f36e7531SZach Atkins                cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol
704f36e7531SZach Atkins                diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol)
7051b16049aSZach Atkins                if diff:
706f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name
707f36e7531SZach Atkins                    shutil.move(out_file, save_path)
708f36e7531SZach Atkins                    test_case.add_failure_info(f'cgns: {save_path}', output=diff)
7091b16049aSZach Atkins                else:
710f36e7531SZach Atkins                    out_file.unlink()
711f36e7531SZach Atkins        # expected ASCII output
712f36e7531SZach Atkins        for ref_file in ref_ascii:
713f36e7531SZach Atkins            ref_name = ref_file.name
714f36e7531SZach Atkins            out_file = Path.cwd() / ref_name
715f36e7531SZach Atkins            if not ref_file.is_file():
716f36e7531SZach Atkins                # remove _{ceed_backend} from path name
717f36e7531SZach Atkins                ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix)
718f36e7531SZach Atkins            if not ref_file.is_file():
719f36e7531SZach Atkins                test_case.add_failure_info('ascii', output=f'{ref_file} not found')
720f36e7531SZach Atkins            elif not out_file.is_file():
721f36e7531SZach Atkins                test_case.add_failure_info('ascii', output=f'{out_file} not found')
722f36e7531SZach Atkins            else:
723f36e7531SZach Atkins                diff = diff_ascii(out_file, ref_file, backend)
724f36e7531SZach Atkins                if diff:
725f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / ref_name
726f36e7531SZach Atkins                    shutil.move(out_file, save_path)
727f36e7531SZach Atkins                    test_case.add_failure_info(f'ascii: {save_path}', output=diff)
728f36e7531SZach Atkins                else:
729f36e7531SZach Atkins                    out_file.unlink()
7301b16049aSZach Atkins
7311b16049aSZach Atkins    # store result
7321b16049aSZach Atkins    test_case.args = ' '.join(str(arg) for arg in run_args)
733f36e7531SZach Atkins    output_str = test_case_output_string(test_case, spec, mode, backend, test, index, verbose)
73419868e18SZach Atkins
73519868e18SZach Atkins    return test_case, output_str
73619868e18SZach Atkins
73719868e18SZach Atkins
73819868e18SZach Atkinsdef init_process():
73919868e18SZach Atkins    """Initialize multiprocessing process"""
74019868e18SZach Atkins    # set up error handler
74119868e18SZach Atkins    global my_env
74219868e18SZach Atkins    my_env = os.environ.copy()
74319868e18SZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
74419868e18SZach Atkins
74519868e18SZach Atkins
74678cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
747f36e7531SZach Atkins              suite_spec: SuiteSpec, pool_size: int = 1, search: str = ".*", verbose: bool = False) -> TestSuite:
74819868e18SZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
74919868e18SZach Atkins
75019868e18SZach Atkins    Args:
75119868e18SZach Atkins        test (str): Name of test
7528938a869SZach Atkins        ceed_backends (List[str]): List of libCEED backends
75319868e18SZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
75419868e18SZach Atkins        nproc (int): Number of MPI processes to use when running each test case
75519868e18SZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
75619868e18SZach Atkins        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
757f36e7531SZach Atkins        search (str, optional): Regular expression used to match tests. Defaults to ".*".
758f36e7531SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
75919868e18SZach Atkins
76019868e18SZach Atkins    Returns:
76119868e18SZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
76219868e18SZach Atkins    """
763f36e7531SZach Atkins    test_specs: List[TestSpec] = [
764f36e7531SZach Atkins        t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE)
765f36e7531SZach Atkins    ]
766f36e7531SZach Atkins    suite_spec.test_failure_artifacts_path.mkdir(parents=True, exist_ok=True)
76719868e18SZach Atkins    if mode is RunMode.TAP:
7688938a869SZach Atkins        print('TAP version 13')
7698938a869SZach Atkins        print(f'1..{len(test_specs)}')
77019868e18SZach Atkins
77119868e18SZach Atkins    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
772f36e7531SZach Atkins        async_outputs: List[List[mp.pool.AsyncResult]] = [
773f36e7531SZach Atkins            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec, verbose))
7748938a869SZach Atkins             for (i, backend) in enumerate(ceed_backends, start=1)]
7758938a869SZach Atkins            for spec in test_specs
7768938a869SZach Atkins        ]
77719868e18SZach Atkins
77819868e18SZach Atkins        test_cases = []
7798938a869SZach Atkins        for (i, subtest) in enumerate(async_outputs, start=1):
7808938a869SZach Atkins            is_new_subtest = True
7818938a869SZach Atkins            subtest_ok = True
7828938a869SZach Atkins            for async_output in subtest:
78319868e18SZach Atkins                test_case, print_output = async_output.get()
78419868e18SZach Atkins                test_cases.append(test_case)
7858938a869SZach Atkins                if is_new_subtest and mode == RunMode.TAP:
7868938a869SZach Atkins                    is_new_subtest = False
7878938a869SZach Atkins                    print(f'# Subtest: {test_case.category}')
7888938a869SZach Atkins                    print(f'    1..{len(ceed_backends)}')
78919868e18SZach Atkins                print(print_output, end='')
7908938a869SZach Atkins                if test_case.is_failure() or test_case.is_error():
7918938a869SZach Atkins                    subtest_ok = False
7928938a869SZach Atkins            if mode == RunMode.TAP:
7938938a869SZach Atkins                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
7941b16049aSZach Atkins
7951b16049aSZach Atkins    return TestSuite(test, test_cases)
7961b16049aSZach Atkins
7971b16049aSZach Atkins
7981b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
7991b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
8001b16049aSZach Atkins
8011b16049aSZach Atkins    Args:
8021b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
8031b16049aSZach Atkins        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
8041b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
8051b16049aSZach Atkins    """
806f36e7531SZach Atkins    output_file = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
8071b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
8081b16049aSZach Atkins
8091b16049aSZach Atkins
8101b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
8111b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
8121b16049aSZach Atkins
8131b16049aSZach Atkins    Args:
8141b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
8151b16049aSZach Atkins
8161b16049aSZach Atkins    Returns:
8171b16049aSZach Atkins        bool: True if any test cases failed
8181b16049aSZach Atkins    """
8191b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
820