xref: /libCEED/tests/junit_common.py (revision 6a6b797f790a7f197cde448212987b3ead5d18fa)
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
1565f1423ffSZach Atkins    @property
1575f1423ffSZach Atkins    def csv_comment_diff_fn(self):  # -> Any | Callable[..., None]:
1585f1423ffSZach Atkins        return getattr(self, '_csv_comment_diff_fn', None)
1595f1423ffSZach Atkins
1605f1423ffSZach Atkins    @csv_comment_diff_fn.setter
1615f1423ffSZach Atkins    def csv_comment_diff_fn(self, test_fn):
1625f1423ffSZach Atkins        self._csv_comment_diff_fn = test_fn
1635f1423ffSZach Atkins
1645f1423ffSZach Atkins    @property
1655f1423ffSZach Atkins    def csv_comment_str(self):
1665f1423ffSZach Atkins        return getattr(self, '_csv_comment_str', '#')
1675f1423ffSZach Atkins
1685f1423ffSZach Atkins    @csv_comment_str.setter
1695f1423ffSZach Atkins    def csv_comment_str(self, comment_str):
1705f1423ffSZach Atkins        self._csv_comment_str = comment_str
1715f1423ffSZach Atkins
172f36e7531SZach Atkins    def post_test_hook(self, test: str, spec: TestSpec, backend: str) -> None:
1731b16049aSZach Atkins        """Function callback ran after each test case
1741b16049aSZach Atkins
1751b16049aSZach Atkins        Args:
1761b16049aSZach Atkins            test (str): Name of test
1771b16049aSZach Atkins            spec (TestSpec): Test case specification
1781b16049aSZach Atkins        """
1791b16049aSZach Atkins        pass
1801b16049aSZach Atkins
1811b16049aSZach Atkins    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1821b16049aSZach Atkins        """Check if a test case should be skipped prior to running, returning the reason for skipping
1831b16049aSZach Atkins
1841b16049aSZach Atkins        Args:
1851b16049aSZach Atkins            test (str): Name of test
1861b16049aSZach Atkins            spec (TestSpec): Test case specification
1871b16049aSZach Atkins            resource (str): libCEED backend
1881b16049aSZach Atkins            nproc (int): Number of MPI processes to use when running test case
1891b16049aSZach Atkins
1901b16049aSZach Atkins        Returns:
1911b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if test case should not be skipped
1921b16049aSZach Atkins        """
1931b16049aSZach Atkins        return None
1941b16049aSZach Atkins
1951b16049aSZach Atkins    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1961b16049aSZach Atkins        """Check if a test case should be allowed to fail, based on its stderr output
1971b16049aSZach Atkins
1981b16049aSZach Atkins        Args:
1991b16049aSZach Atkins            test (str): Name of test
2001b16049aSZach Atkins            spec (TestSpec): Test case specification
2011b16049aSZach Atkins            resource (str): libCEED backend
2021b16049aSZach Atkins            stderr (str): Standard error output from test case execution
2031b16049aSZach Atkins
2041b16049aSZach Atkins        Returns:
20519868e18SZach Atkins            Optional[str]: Skip reason, or `None` if unexpected error
2061b16049aSZach Atkins        """
2071b16049aSZach Atkins        return None
2081b16049aSZach Atkins
20978cb100bSJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
2101b16049aSZach Atkins        """Check whether a test case is expected to fail and if it failed expectedly
2111b16049aSZach Atkins
2121b16049aSZach Atkins        Args:
2131b16049aSZach Atkins            test (str): Name of test
2141b16049aSZach Atkins            spec (TestSpec): Test case specification
2151b16049aSZach Atkins            resource (str): libCEED backend
2161b16049aSZach Atkins            stderr (str): Standard error output from test case execution
2171b16049aSZach Atkins
2181b16049aSZach Atkins        Returns:
2191b16049aSZach Atkins            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
2201b16049aSZach Atkins        """
2211b16049aSZach Atkins        return '', True
2221b16049aSZach Atkins
2231b16049aSZach Atkins    def check_allowed_stdout(self, test: str) -> bool:
2241b16049aSZach Atkins        """Check whether a test is allowed to print console output
2251b16049aSZach Atkins
2261b16049aSZach Atkins        Args:
2271b16049aSZach Atkins            test (str): Name of test
2281b16049aSZach Atkins
2291b16049aSZach Atkins        Returns:
2301b16049aSZach Atkins            bool: True if the test is allowed to print console output
2311b16049aSZach Atkins        """
2321b16049aSZach Atkins        return False
2331b16049aSZach Atkins
2341b16049aSZach Atkins
2351b16049aSZach Atkinsdef has_cgnsdiff() -> bool:
2361b16049aSZach Atkins    """Check whether `cgnsdiff` is an executable program in the current environment
2371b16049aSZach Atkins
2381b16049aSZach Atkins    Returns:
2391b16049aSZach Atkins        bool: True if `cgnsdiff` is found
2401b16049aSZach Atkins    """
2411b16049aSZach Atkins    my_env: dict = os.environ.copy()
2421b16049aSZach Atkins    proc = subprocess.run('cgnsdiff',
2431b16049aSZach Atkins                          shell=True,
2441b16049aSZach Atkins                          stdout=subprocess.PIPE,
2451b16049aSZach Atkins                          stderr=subprocess.PIPE,
2461b16049aSZach Atkins                          env=my_env)
2471b16049aSZach Atkins    return 'not found' not in proc.stderr.decode('utf-8')
2481b16049aSZach Atkins
2491b16049aSZach Atkins
25078cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
2511b16049aSZach Atkins    """Helper function, checks if any of the substrings are included in the base string
2521b16049aSZach Atkins
2531b16049aSZach Atkins    Args:
2541b16049aSZach Atkins        base (str): Base string to search in
2558938a869SZach Atkins        substrings (List[str]): List of potential substrings
2561b16049aSZach Atkins
2571b16049aSZach Atkins    Returns:
2581b16049aSZach Atkins        bool: True if any substrings are included in base string
2591b16049aSZach Atkins    """
2601b16049aSZach Atkins    return any((sub in base for sub in substrings))
2611b16049aSZach Atkins
2621b16049aSZach Atkins
26378cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
2641b16049aSZach Atkins    """Helper function, checks if the base string is prefixed by any of `prefixes`
2651b16049aSZach Atkins
2661b16049aSZach Atkins    Args:
2671b16049aSZach Atkins        base (str): Base string to search
2688938a869SZach Atkins        prefixes (List[str]): List of potential prefixes
2691b16049aSZach Atkins
2701b16049aSZach Atkins    Returns:
2711b16049aSZach Atkins        bool: True if base string is prefixed by any of the prefixes
2721b16049aSZach Atkins    """
2731b16049aSZach Atkins    return any((base.startswith(prefix) for prefix in prefixes))
2741b16049aSZach Atkins
2751b16049aSZach Atkins
276f36e7531SZach Atkinsdef find_matching(line: str, open: str = '(', close: str = ')') -> Tuple[int, int]:
277f36e7531SZach Atkins    """Find the start and end positions of the first outer paired delimeters
278f36e7531SZach Atkins
279f36e7531SZach Atkins    Args:
280f36e7531SZach Atkins        line (str): Line to search
281f36e7531SZach Atkins        open (str, optional): Opening delimiter, must be different than `close`. Defaults to '('.
282f36e7531SZach Atkins        close (str, optional): Closing delimeter, must be different than `open`. Defaults to ')'.
283f36e7531SZach Atkins
284f36e7531SZach Atkins    Raises:
285f36e7531SZach Atkins        RuntimeError: If open or close is not a single character
286f36e7531SZach Atkins        RuntimeError: If open and close are the same characters
287f36e7531SZach Atkins
288f36e7531SZach Atkins    Returns:
289f36e7531SZach Atkins        Tuple[int]: If matching delimeters are found, return indices in `list`. Otherwise, return end < start.
290f36e7531SZach Atkins    """
291f36e7531SZach Atkins    if len(open) != 1 or len(close) != 1:
292f36e7531SZach Atkins        raise RuntimeError("`open` and `close` must be single characters")
293f36e7531SZach Atkins    if open == close:
294f36e7531SZach Atkins        raise RuntimeError("`open` and `close` must be different characters")
295f36e7531SZach Atkins    start: int = line.find(open)
296f36e7531SZach Atkins    if start < 0:
297f36e7531SZach Atkins        return -1, -1
298f36e7531SZach Atkins    count: int = 1
299f36e7531SZach Atkins    for i in range(start + 1, len(line)):
300f36e7531SZach Atkins        if line[i] == open:
301f36e7531SZach Atkins            count += 1
302f36e7531SZach Atkins        if line[i] == close:
303f36e7531SZach Atkins            count -= 1
304f36e7531SZach Atkins            if count == 0:
305f36e7531SZach Atkins                return start, i
306f36e7531SZach Atkins    return start, -1
307f36e7531SZach Atkins
308f36e7531SZach Atkins
3097b1ec880SZach Atkinsdef parse_test_line(line: str, fallback_name: str = '') -> TestSpec:
3101b16049aSZach Atkins    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
3111b16049aSZach Atkins
3121b16049aSZach Atkins    Args:
3131b16049aSZach Atkins        line (str): String containing TESTARGS specification and CLI arguments
3141b16049aSZach Atkins
3151b16049aSZach Atkins    Returns:
3161b16049aSZach Atkins        TestSpec: Parsed specification of test case
3171b16049aSZach Atkins    """
318f36e7531SZach Atkins    test_fields = fields(TestSpec)
319f36e7531SZach Atkins    field_names = [f.name for f in test_fields]
320f36e7531SZach Atkins    known: Dict = dict()
321f36e7531SZach Atkins    other: Dict = dict()
322f36e7531SZach Atkins    if line[0] == "(":
323f36e7531SZach Atkins        # have key/value pairs to parse
324f36e7531SZach Atkins        start, end = find_matching(line)
325f36e7531SZach Atkins        if end < start:
326f36e7531SZach Atkins            raise ParseError(f"Mismatched parentheses in TESTCASE: {line}")
327f36e7531SZach Atkins
328f36e7531SZach Atkins        keyvalues_str = line[start:end + 1]
329f36e7531SZach Atkins        keyvalues_pattern = re.compile(r'''
330f36e7531SZach Atkins            (?:\(\s*|\s*,\s*)   # start with open parentheses or comma, no capture
331f36e7531SZach Atkins            ([A-Za-z]+[\w\-]+)  # match key starting with alpha, containing alphanumeric, _, or -; captured as Group 1
332f36e7531SZach Atkins            \s*=\s*             # key is followed by = (whitespace ignored)
333f36e7531SZach Atkins            (?:                 # uncaptured group for OR
334f36e7531SZach Atkins              "((?:[^"]|\\")+)" #   match quoted value (any internal " must be escaped as \"); captured as Group 2
335f36e7531SZach Atkins            | ([^=]+)           #   OR match unquoted value (no equals signs allowed); captured as Group 3
336f36e7531SZach Atkins            )                   # end uncaptured group for OR
337f36e7531SZach Atkins            \s*(?=,|\))         # lookahead for either next comma or closing parentheses
338f36e7531SZach Atkins        ''', re.VERBOSE)
339f36e7531SZach Atkins
340f36e7531SZach Atkins        for match in re.finditer(keyvalues_pattern, keyvalues_str):
341f36e7531SZach Atkins            if not match:  # empty
342f36e7531SZach Atkins                continue
343f36e7531SZach Atkins            key = match.group(1)
344f36e7531SZach Atkins            value = match.group(2) if match.group(2) else match.group(3)
345f36e7531SZach Atkins            try:
346f36e7531SZach Atkins                index = field_names.index(key)
347f36e7531SZach Atkins                if key == "only":  # weird bc only is a list
348f36e7531SZach Atkins                    value = [constraint.strip() for constraint in value.split(',')]
349f36e7531SZach Atkins                try:
350f36e7531SZach Atkins                    # TODO: stop supporting python <=3.8
351f36e7531SZach Atkins                    known[key] = test_fields[index].type(value)  # type: ignore
352f36e7531SZach Atkins                except TypeError:
353f36e7531SZach Atkins                    # TODO: this is still liable to fail for complex types
354f36e7531SZach Atkins                    known[key] = get_origin(test_fields[index].type)(value)  # type: ignore
355f36e7531SZach Atkins            except ValueError:
356f36e7531SZach Atkins                other[key] = value
357f36e7531SZach Atkins
358f36e7531SZach Atkins        line = line[end + 1:]
359f36e7531SZach Atkins
3607b1ec880SZach Atkins    if not 'name' in known.keys():
3617b1ec880SZach Atkins        known['name'] = fallback_name
3627b1ec880SZach Atkins
363f36e7531SZach Atkins    args_pattern = re.compile(r'''
364f36e7531SZach Atkins        \s+(            # remove leading space
365f36e7531SZach Atkins            (?:"[^"]+") # match quoted CLI option
366f36e7531SZach Atkins          | (?:[\S]+)   # match anything else that is space separated
367f36e7531SZach Atkins        )
368f36e7531SZach Atkins    ''', re.VERBOSE)
369f36e7531SZach Atkins    args: List[str] = re.findall(args_pattern, line)
370f36e7531SZach Atkins    for k, v in other.items():
371f36e7531SZach Atkins        print(f"warning, unknown TESTCASE option for test '{known['name']}': {k}={v}")
372f36e7531SZach Atkins    return TestSpec(**known, key_values=other, args=args)
3731b16049aSZach Atkins
3741b16049aSZach Atkins
37578cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
3761b16049aSZach Atkins    """Parse all test cases from a given source file
3771b16049aSZach Atkins
3781b16049aSZach Atkins    Args:
3791b16049aSZach Atkins        source_file (Path): Path to source file
3801b16049aSZach Atkins
3811b16049aSZach Atkins    Raises:
3821b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
3831b16049aSZach Atkins
3841b16049aSZach Atkins    Returns:
3858938a869SZach Atkins        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
3861b16049aSZach Atkins    """
3871b16049aSZach Atkins    comment_str: str = ''
3888c81f8b0SPeter Munch    if source_file.suffix in ['.c', '.cc', '.cpp']:
3891b16049aSZach Atkins        comment_str = '//'
3901b16049aSZach Atkins    elif source_file.suffix in ['.py']:
3911b16049aSZach Atkins        comment_str = '#'
3921b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
3931b16049aSZach Atkins        comment_str = 'C_'
3941b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
3951b16049aSZach Atkins        comment_str = '! '
3961b16049aSZach Atkins    else:
3971b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
3981b16049aSZach Atkins
3997b1ec880SZach Atkins    return [parse_test_line(line.strip(comment_str).removeprefix("TESTARGS"), source_file.stem)
4001b16049aSZach Atkins            for line in source_file.read_text().splitlines()
4017b1ec880SZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec(source_file.stem, args=['{ceed_resource}'])]
4021b16049aSZach Atkins
4031b16049aSZach Atkins
404f36e7531SZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float, rel_tol: float,
40512235d7fSJames Wright             comment_str: str = '#', comment_func: Optional[Callable[[str, str], Optional[str]]] = None) -> str:
4061b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
4071b16049aSZach Atkins
4081b16049aSZach Atkins    Args:
4091b16049aSZach Atkins        test_csv (Path): Path to output CSV results
4101b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
411f36e7531SZach Atkins        zero_tol (float): Tolerance below which values are considered to be zero.
412f36e7531SZach Atkins        rel_tol (float): Relative tolerance for comparing non-zero values.
41312235d7fSJames Wright        comment_str (str, optional): String to denoting commented line
41412235d7fSJames Wright        comment_func (Callable, optional): Function to determine if test and true line are different
4151b16049aSZach Atkins
4161b16049aSZach Atkins    Returns:
4171b16049aSZach Atkins        str: Diff output between result and expected CSVs
4181b16049aSZach Atkins    """
41978cb100bSJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
42078cb100bSJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
42169ef23b6SZach Atkins    # Files should not be empty
42269ef23b6SZach Atkins    if len(test_lines) == 0:
42369ef23b6SZach Atkins        return f'No lines found in test output {test_csv}'
42469ef23b6SZach Atkins    if len(true_lines) == 0:
42569ef23b6SZach Atkins        return f'No lines found in test source {true_csv}'
42612235d7fSJames Wright    if len(test_lines) != len(true_lines):
42712235d7fSJames Wright        return f'Number of lines in {test_csv} and {true_csv} do not match'
42812235d7fSJames Wright
42912235d7fSJames Wright    # Process commented lines
43012235d7fSJames Wright    uncommented_lines: List[int] = []
43112235d7fSJames Wright    for n, (test_line, true_line) in enumerate(zip(test_lines, true_lines)):
43212235d7fSJames Wright        if test_line[0] == comment_str and true_line[0] == comment_str:
43312235d7fSJames Wright            if comment_func:
43412235d7fSJames Wright                output = comment_func(test_line, true_line)
43512235d7fSJames Wright                if output:
43612235d7fSJames Wright                    return output
43712235d7fSJames Wright        elif test_line[0] == comment_str and true_line[0] != comment_str:
43812235d7fSJames Wright            return f'Commented line found in {test_csv} at line {n} but not in {true_csv}'
43912235d7fSJames Wright        elif test_line[0] != comment_str and true_line[0] == comment_str:
44012235d7fSJames Wright            return f'Commented line found in {true_csv} at line {n} but not in {test_csv}'
44112235d7fSJames Wright        else:
44212235d7fSJames Wright            uncommented_lines.append(n)
44312235d7fSJames Wright
44412235d7fSJames Wright    # Remove commented lines
44512235d7fSJames Wright    test_lines = [test_lines[line] for line in uncommented_lines]
44612235d7fSJames Wright    true_lines = [true_lines[line] for line in uncommented_lines]
4471b16049aSZach Atkins
44869ef23b6SZach Atkins    test_reader: csv.DictReader = csv.DictReader(test_lines)
44969ef23b6SZach Atkins    true_reader: csv.DictReader = csv.DictReader(true_lines)
450f36e7531SZach Atkins    if not test_reader.fieldnames:
451f36e7531SZach Atkins        return f'No CSV columns found in test output {test_csv}'
452f36e7531SZach Atkins    if not true_reader.fieldnames:
453f36e7531SZach Atkins        return f'No CSV columns found in test source {true_csv}'
45469ef23b6SZach Atkins    if test_reader.fieldnames != true_reader.fieldnames:
4551b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
4561b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
4571b16049aSZach Atkins
45878cb100bSJames Wright    diff_lines: List[str] = list()
45969ef23b6SZach Atkins    for test_line, true_line in zip(test_reader, true_reader):
46069ef23b6SZach Atkins        for key in test_reader.fieldnames:
46169ef23b6SZach Atkins            # Check if the value is numeric
46269ef23b6SZach Atkins            try:
46369ef23b6SZach Atkins                true_val: float = float(true_line[key])
46469ef23b6SZach Atkins                test_val: float = float(test_line[key])
4651b16049aSZach Atkins                true_zero: bool = abs(true_val) < zero_tol
4661b16049aSZach Atkins                test_zero: bool = abs(test_val) < zero_tol
4671b16049aSZach Atkins                fail: bool = False
4681b16049aSZach Atkins                if true_zero:
4691b16049aSZach Atkins                    fail = not test_zero
4701b16049aSZach Atkins                else:
4711b16049aSZach Atkins                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
4721b16049aSZach Atkins                if fail:
47369ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
47469ef23b6SZach Atkins            except ValueError:
47569ef23b6SZach Atkins                if test_line[key] != true_line[key]:
47669ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
47769ef23b6SZach Atkins
4781b16049aSZach Atkins    return '\n'.join(diff_lines)
4791b16049aSZach Atkins
4801b16049aSZach Atkins
481f36e7531SZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, cgns_tol: float) -> str:
4821b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
4831b16049aSZach Atkins
4841b16049aSZach Atkins    Args:
4851b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
4861b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
487f36e7531SZach Atkins        cgns_tol (float): Tolerance for comparing floating-point values
4881b16049aSZach Atkins
4891b16049aSZach Atkins    Returns:
4901b16049aSZach Atkins        str: Diff output between result and expected CGNS files
4911b16049aSZach Atkins    """
4921b16049aSZach Atkins    my_env: dict = os.environ.copy()
4931b16049aSZach Atkins
49483ebc4c4SJeremy L Thompson    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{cgns_tol}', str(test_cgns), str(true_cgns)]
4951b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
4961b16049aSZach Atkins                          shell=True,
4971b16049aSZach Atkins                          stdout=subprocess.PIPE,
4981b16049aSZach Atkins                          stderr=subprocess.PIPE,
4991b16049aSZach Atkins                          env=my_env)
5001b16049aSZach Atkins
5011b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
5021b16049aSZach Atkins
5031b16049aSZach Atkins
504f36e7531SZach Atkinsdef diff_ascii(test_file: Path, true_file: Path, backend: str) -> str:
505f36e7531SZach Atkins    """Compare ASCII results against an expected ASCII file
506f36e7531SZach Atkins
507f36e7531SZach Atkins    Args:
508f36e7531SZach Atkins        test_file (Path): Path to output ASCII file
509f36e7531SZach Atkins        true_file (Path): Path to expected ASCII file
510f36e7531SZach Atkins
511f36e7531SZach Atkins    Returns:
512f36e7531SZach Atkins        str: Diff output between result and expected ASCII files
513f36e7531SZach Atkins    """
514f36e7531SZach Atkins    tmp_backend: str = backend.replace('/', '-')
515f36e7531SZach Atkins    true_str: str = true_file.read_text().replace('{ceed_resource}', tmp_backend)
516f36e7531SZach Atkins    diff = list(difflib.unified_diff(test_file.read_text().splitlines(keepends=True),
517f36e7531SZach Atkins                                     true_str.splitlines(keepends=True),
518f36e7531SZach Atkins                                     fromfile=str(test_file),
519f36e7531SZach Atkins                                     tofile=str(true_file)))
520f36e7531SZach Atkins    return ''.join(diff)
521f36e7531SZach Atkins
522f36e7531SZach Atkins
523e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
524f36e7531SZach Atkins                            backend: str, test: str, index: int, verbose: bool) -> str:
525e17e35bbSJames Wright    output_str = ''
526e17e35bbSJames Wright    if mode is RunMode.TAP:
527e17e35bbSJames Wright        # print incremental output if TAP mode
528e17e35bbSJames Wright        if test_case.is_skipped():
529e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
530e17e35bbSJames Wright        elif test_case.is_failure() or test_case.is_error():
531f36e7531SZach Atkins            output_str += f'    not ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
532e17e35bbSJames Wright        else:
533f36e7531SZach Atkins            output_str += f'    ok {index} - {spec.name}, {backend} ({test_case.elapsed_sec} s)\n'
534f36e7531SZach Atkins        if test_case.is_failure() or test_case.is_error() or verbose:
535e17e35bbSJames Wright            output_str += f'      ---\n'
536e17e35bbSJames Wright            if spec.only:
537e17e35bbSJames Wright                output_str += f'      only: {",".join(spec.only)}\n'
538e17e35bbSJames Wright            output_str += f'      args: {test_case.args}\n'
539f36e7531SZach Atkins            if spec.csv_ztol > 0:
540f36e7531SZach Atkins                output_str += f'      csv_ztol: {spec.csv_ztol}\n'
541f36e7531SZach Atkins            if spec.csv_rtol > 0:
542f36e7531SZach Atkins                output_str += f'      csv_rtol: {spec.csv_rtol}\n'
543f36e7531SZach Atkins            if spec.cgns_tol > 0:
544f36e7531SZach Atkins                output_str += f'      cgns_tol: {spec.cgns_tol}\n'
545f36e7531SZach Atkins            for k, v in spec.key_values.items():
546f36e7531SZach Atkins                output_str += f'      {k}: {v}\n'
547e17e35bbSJames Wright            if test_case.is_error():
548e17e35bbSJames Wright                output_str += f'      error: {test_case.errors[0]["message"]}\n'
549e17e35bbSJames Wright            if test_case.is_failure():
550f36e7531SZach Atkins                output_str += f'      failures:\n'
551e17e35bbSJames Wright                for i, failure in enumerate(test_case.failures):
552f36e7531SZach Atkins                    output_str += f'        -\n'
553e17e35bbSJames Wright                    output_str += f'          message: {failure["message"]}\n'
554e17e35bbSJames Wright                    if failure["output"]:
555e17e35bbSJames Wright                        out = failure["output"].strip().replace('\n', '\n            ')
556e17e35bbSJames Wright                        output_str += f'          output: |\n            {out}\n'
557e17e35bbSJames Wright            output_str += f'      ...\n'
558e17e35bbSJames Wright    else:
559e17e35bbSJames Wright        # print error or failure information if JUNIT mode
560e17e35bbSJames Wright        if test_case.is_error() or test_case.is_failure():
561e17e35bbSJames Wright            output_str += f'Test: {test} {spec.name}\n'
562e17e35bbSJames Wright            output_str += f'  $ {test_case.args}\n'
563e17e35bbSJames Wright            if test_case.is_error():
564e17e35bbSJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
565e17e35bbSJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
566e17e35bbSJames Wright            if test_case.is_failure():
567e17e35bbSJames Wright                for failure in test_case.failures:
568e17e35bbSJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
569e17e35bbSJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
570e17e35bbSJames Wright    return output_str
571e17e35bbSJames Wright
572e17e35bbSJames Wright
573f36e7531SZach Atkinsdef save_failure_artifact(suite_spec: SuiteSpec, file: Path) -> Path:
574f36e7531SZach Atkins    """Attach a file to a test case
575f36e7531SZach Atkins
576f36e7531SZach Atkins    Args:
577f36e7531SZach Atkins        test_case (TestCase): Test case to attach the file to
578f36e7531SZach Atkins        file (Path): Path to the file to attach
579f36e7531SZach Atkins    """
580f36e7531SZach Atkins    save_path: Path = suite_spec.test_failure_artifacts_path / file.name
581f36e7531SZach Atkins    shutil.copyfile(file, save_path)
582f36e7531SZach Atkins    return save_path
583f36e7531SZach Atkins
584f36e7531SZach Atkins
58519868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str,
586f36e7531SZach Atkins             mode: RunMode, nproc: int, suite_spec: SuiteSpec, verbose: bool = False) -> TestCase:
58719868e18SZach Atkins    """Run a single test case and backend combination
5881b16049aSZach Atkins
5891b16049aSZach Atkins    Args:
5908938a869SZach Atkins        index (int): Index of backend for current spec
59119868e18SZach Atkins        test (str): Path to test
59219868e18SZach Atkins        spec (TestSpec): Specification of test case
59319868e18SZach Atkins        backend (str): CEED backend
59419868e18SZach Atkins        mode (RunMode): Output mode
59519868e18SZach Atkins        nproc (int): Number of MPI processes to use when running test case
59619868e18SZach Atkins        suite_spec (SuiteSpec): Specification of test suite
597f36e7531SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
5981b16049aSZach Atkins
5991b16049aSZach Atkins    Returns:
60019868e18SZach Atkins        TestCase: Test case result
6011b16049aSZach Atkins    """
6021b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
6038938a869SZach Atkins    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
6041b16049aSZach Atkins
6051b16049aSZach Atkins    if '{ceed_resource}' in run_args:
60619868e18SZach Atkins        run_args[run_args.index('{ceed_resource}')] = backend
6078938a869SZach Atkins    for i, arg in enumerate(run_args):
6088938a869SZach Atkins        if '{ceed_resource}' in arg:
6098938a869SZach Atkins            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
6101b16049aSZach Atkins    if '{nproc}' in run_args:
6111b16049aSZach Atkins        run_args[run_args.index('{nproc}')] = f'{nproc}'
6121b16049aSZach Atkins    elif nproc > 1 and source_path.suffix != '.py':
6131b16049aSZach Atkins        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
6141b16049aSZach Atkins
6151b16049aSZach Atkins    # run test
616f36e7531SZach Atkins    skip_reason: Optional[str] = suite_spec.check_pre_skip(test, spec, backend, nproc)
6171b16049aSZach Atkins    if skip_reason:
61819868e18SZach Atkins        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6191b16049aSZach Atkins                                       elapsed_sec=0,
6201b16049aSZach Atkins                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
6211b16049aSZach Atkins                                       stdout='',
6228938a869SZach Atkins                                       stderr='',
6238938a869SZach Atkins                                       category=spec.name,)
6241b16049aSZach Atkins        test_case.add_skipped_info(skip_reason)
6251b16049aSZach Atkins    else:
6261b16049aSZach Atkins        start: float = time.time()
6271b16049aSZach Atkins        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
6281b16049aSZach Atkins                              shell=True,
6291b16049aSZach Atkins                              stdout=subprocess.PIPE,
6301b16049aSZach Atkins                              stderr=subprocess.PIPE,
6311b16049aSZach Atkins                              env=my_env)
6321b16049aSZach Atkins
63319868e18SZach Atkins        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
6341b16049aSZach Atkins                             classname=source_path.parent,
6351b16049aSZach Atkins                             elapsed_sec=time.time() - start,
6361b16049aSZach Atkins                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
6371b16049aSZach Atkins                             stdout=proc.stdout.decode('utf-8'),
6381b16049aSZach Atkins                             stderr=proc.stderr.decode('utf-8'),
6398938a869SZach Atkins                             allow_multiple_subelements=True,
6408938a869SZach Atkins                             category=spec.name,)
64178cb100bSJames Wright        ref_csvs: List[Path] = []
642f36e7531SZach Atkins        ref_ascii: List[Path] = []
6435f1423ffSZach Atkins        output_files: List[str] = [arg.split(':')[1] for arg in run_args if arg.startswith('ascii:')]
64497fab443SJeremy L Thompson        if output_files:
6455f1423ffSZach Atkins            ref_csvs = [suite_spec.get_output_path(test, file)
646f36e7531SZach Atkins                        for file in output_files if file.endswith('.csv')]
6475f1423ffSZach Atkins            ref_ascii = [suite_spec.get_output_path(test, file)
648f36e7531SZach Atkins                         for file in output_files if not file.endswith('.csv')]
64978cb100bSJames Wright        ref_cgns: List[Path] = []
6505f1423ffSZach Atkins        output_files = [arg.split(':')[1] for arg in run_args if arg.startswith('cgns:')]
65197fab443SJeremy L Thompson        if output_files:
6525f1423ffSZach Atkins            ref_cgns = [suite_spec.get_output_path(test, file) for file in output_files]
6531b16049aSZach Atkins        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
654f36e7531SZach Atkins        suite_spec.post_test_hook(test, spec, backend)
6551b16049aSZach Atkins
6561b16049aSZach Atkins    # check allowed failures
6571b16049aSZach Atkins    if not test_case.is_skipped() and test_case.stderr:
658f36e7531SZach Atkins        skip_reason: Optional[str] = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
6591b16049aSZach Atkins        if skip_reason:
6601b16049aSZach Atkins            test_case.add_skipped_info(skip_reason)
6611b16049aSZach Atkins
6621b16049aSZach Atkins    # check required failures
6631b16049aSZach Atkins    if not test_case.is_skipped():
6642fee3251SSebastian Grimberg        required_message, did_fail = suite_spec.check_required_failure(
66519868e18SZach Atkins            test, spec, backend, test_case.stderr)
6661b16049aSZach Atkins        if required_message and did_fail:
6671b16049aSZach Atkins            test_case.status = f'fails with required: {required_message}'
6681b16049aSZach Atkins        elif required_message:
6691b16049aSZach Atkins            test_case.add_failure_info(f'required failure missing: {required_message}')
6701b16049aSZach Atkins
6711b16049aSZach Atkins    # classify other results
6721b16049aSZach Atkins    if not test_case.is_skipped() and not test_case.status:
6731b16049aSZach Atkins        if test_case.stderr:
6741b16049aSZach Atkins            test_case.add_failure_info('stderr', test_case.stderr)
6751b16049aSZach Atkins        if proc.returncode != 0:
6761b16049aSZach Atkins            test_case.add_error_info(f'returncode = {proc.returncode}')
6771b16049aSZach Atkins        if ref_stdout.is_file():
6781b16049aSZach Atkins            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
6791b16049aSZach Atkins                                             test_case.stdout.splitlines(keepends=True),
6801b16049aSZach Atkins                                             fromfile=str(ref_stdout),
6811b16049aSZach Atkins                                             tofile='New'))
6821b16049aSZach Atkins            if diff:
6831b16049aSZach Atkins                test_case.add_failure_info('stdout', output=''.join(diff))
6841b16049aSZach Atkins        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
6851b16049aSZach Atkins            test_case.add_failure_info('stdout', output=test_case.stdout)
6861b16049aSZach Atkins        # expected CSV output
6871b16049aSZach Atkins        for ref_csv in ref_csvs:
6888938a869SZach Atkins            csv_name = ref_csv.name
689f36e7531SZach Atkins            out_file = Path.cwd() / csv_name
6908938a869SZach Atkins            if not ref_csv.is_file():
6918938a869SZach Atkins                # remove _{ceed_backend} from path name
6928938a869SZach Atkins                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
6931b16049aSZach Atkins            if not ref_csv.is_file():
6941b16049aSZach Atkins                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
695f36e7531SZach Atkins            elif not out_file.is_file():
696f36e7531SZach Atkins                test_case.add_failure_info('csv', output=f'{out_file} not found')
6971b16049aSZach Atkins            else:
698f36e7531SZach Atkins                csv_ztol: float = spec.csv_ztol if spec.csv_ztol > 0 else suite_spec.csv_ztol
699f36e7531SZach Atkins                csv_rtol: float = spec.csv_rtol if spec.csv_rtol > 0 else suite_spec.csv_rtol
7005f1423ffSZach Atkins                diff = diff_csv(
7015f1423ffSZach Atkins                    out_file,
7025f1423ffSZach Atkins                    ref_csv,
7035f1423ffSZach Atkins                    csv_ztol,
7045f1423ffSZach Atkins                    csv_rtol,
7055f1423ffSZach Atkins                    suite_spec.csv_comment_str,
7065f1423ffSZach Atkins                    suite_spec.csv_comment_diff_fn)
7071b16049aSZach Atkins                if diff:
708f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / csv_name
709f36e7531SZach Atkins                    shutil.move(out_file, save_path)
710f36e7531SZach Atkins                    test_case.add_failure_info(f'csv: {save_path}', output=diff)
7111b16049aSZach Atkins                else:
712f36e7531SZach Atkins                    out_file.unlink()
7131b16049aSZach Atkins        # expected CGNS output
7141b16049aSZach Atkins        for ref_cgn in ref_cgns:
7158938a869SZach Atkins            cgn_name = ref_cgn.name
716f36e7531SZach Atkins            out_file = Path.cwd() / cgn_name
7178938a869SZach Atkins            if not ref_cgn.is_file():
7188938a869SZach Atkins                # remove _{ceed_backend} from path name
7198938a869SZach Atkins                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
7201b16049aSZach Atkins            if not ref_cgn.is_file():
7211b16049aSZach Atkins                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
722f36e7531SZach Atkins            elif not out_file.is_file():
723f36e7531SZach Atkins                test_case.add_failure_info('cgns', output=f'{out_file} not found')
7241b16049aSZach Atkins            else:
725f36e7531SZach Atkins                cgns_tol = spec.cgns_tol if spec.cgns_tol > 0 else suite_spec.cgns_tol
726f36e7531SZach Atkins                diff = diff_cgns(out_file, ref_cgn, cgns_tol=cgns_tol)
7271b16049aSZach Atkins                if diff:
728f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / cgn_name
729f36e7531SZach Atkins                    shutil.move(out_file, save_path)
730f36e7531SZach Atkins                    test_case.add_failure_info(f'cgns: {save_path}', output=diff)
7311b16049aSZach Atkins                else:
732f36e7531SZach Atkins                    out_file.unlink()
733f36e7531SZach Atkins        # expected ASCII output
734f36e7531SZach Atkins        for ref_file in ref_ascii:
735f36e7531SZach Atkins            ref_name = ref_file.name
736f36e7531SZach Atkins            out_file = Path.cwd() / ref_name
737f36e7531SZach Atkins            if not ref_file.is_file():
738f36e7531SZach Atkins                # remove _{ceed_backend} from path name
739f36e7531SZach Atkins                ref_file = (ref_file.parent / ref_file.name.rsplit('_', 1)[0]).with_suffix(ref_file.suffix)
740f36e7531SZach Atkins            if not ref_file.is_file():
741f36e7531SZach Atkins                test_case.add_failure_info('ascii', output=f'{ref_file} not found')
742f36e7531SZach Atkins            elif not out_file.is_file():
743f36e7531SZach Atkins                test_case.add_failure_info('ascii', output=f'{out_file} not found')
744f36e7531SZach Atkins            else:
745f36e7531SZach Atkins                diff = diff_ascii(out_file, ref_file, backend)
746f36e7531SZach Atkins                if diff:
747f36e7531SZach Atkins                    save_path: Path = suite_spec.test_failure_artifacts_path / ref_name
748f36e7531SZach Atkins                    shutil.move(out_file, save_path)
749f36e7531SZach Atkins                    test_case.add_failure_info(f'ascii: {save_path}', output=diff)
750f36e7531SZach Atkins                else:
751f36e7531SZach Atkins                    out_file.unlink()
7521b16049aSZach Atkins
7531b16049aSZach Atkins    # store result
7541b16049aSZach Atkins    test_case.args = ' '.join(str(arg) for arg in run_args)
755f36e7531SZach Atkins    output_str = test_case_output_string(test_case, spec, mode, backend, test, index, verbose)
75619868e18SZach Atkins
75719868e18SZach Atkins    return test_case, output_str
75819868e18SZach Atkins
75919868e18SZach Atkins
76019868e18SZach Atkinsdef init_process():
76119868e18SZach Atkins    """Initialize multiprocessing process"""
76219868e18SZach Atkins    # set up error handler
76319868e18SZach Atkins    global my_env
76419868e18SZach Atkins    my_env = os.environ.copy()
76519868e18SZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
76619868e18SZach Atkins
76719868e18SZach Atkins
76878cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
769f36e7531SZach Atkins              suite_spec: SuiteSpec, pool_size: int = 1, search: str = ".*", verbose: bool = False) -> TestSuite:
77019868e18SZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
77119868e18SZach Atkins
77219868e18SZach Atkins    Args:
77319868e18SZach Atkins        test (str): Name of test
7748938a869SZach Atkins        ceed_backends (List[str]): List of libCEED backends
77519868e18SZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
77619868e18SZach Atkins        nproc (int): Number of MPI processes to use when running each test case
77719868e18SZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
77819868e18SZach Atkins        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
779f36e7531SZach Atkins        search (str, optional): Regular expression used to match tests. Defaults to ".*".
780f36e7531SZach Atkins        verbose (bool, optional): Print detailed output for all runs, not just failures. Defaults to False.
78119868e18SZach Atkins
78219868e18SZach Atkins    Returns:
78319868e18SZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
78419868e18SZach Atkins    """
785f36e7531SZach Atkins    test_specs: List[TestSpec] = [
786f36e7531SZach Atkins        t for t in get_test_args(suite_spec.get_source_path(test)) if re.search(search, t.name, re.IGNORECASE)
787f36e7531SZach Atkins    ]
788f36e7531SZach Atkins    suite_spec.test_failure_artifacts_path.mkdir(parents=True, exist_ok=True)
78919868e18SZach Atkins    if mode is RunMode.TAP:
7908938a869SZach Atkins        print('TAP version 13')
7918938a869SZach Atkins        print(f'1..{len(test_specs)}')
79219868e18SZach Atkins
79319868e18SZach Atkins    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
794f36e7531SZach Atkins        async_outputs: List[List[mp.pool.AsyncResult]] = [
795f36e7531SZach Atkins            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec, verbose))
7968938a869SZach Atkins             for (i, backend) in enumerate(ceed_backends, start=1)]
7978938a869SZach Atkins            for spec in test_specs
7988938a869SZach Atkins        ]
79919868e18SZach Atkins
80019868e18SZach Atkins        test_cases = []
8018938a869SZach Atkins        for (i, subtest) in enumerate(async_outputs, start=1):
8028938a869SZach Atkins            is_new_subtest = True
8038938a869SZach Atkins            subtest_ok = True
8048938a869SZach Atkins            for async_output in subtest:
80519868e18SZach Atkins                test_case, print_output = async_output.get()
80619868e18SZach Atkins                test_cases.append(test_case)
8078938a869SZach Atkins                if is_new_subtest and mode == RunMode.TAP:
8088938a869SZach Atkins                    is_new_subtest = False
8098938a869SZach Atkins                    print(f'# Subtest: {test_case.category}')
8108938a869SZach Atkins                    print(f'    1..{len(ceed_backends)}')
81119868e18SZach Atkins                print(print_output, end='')
8128938a869SZach Atkins                if test_case.is_failure() or test_case.is_error():
8138938a869SZach Atkins                    subtest_ok = False
8148938a869SZach Atkins            if mode == RunMode.TAP:
8158938a869SZach Atkins                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
8161b16049aSZach Atkins
8171b16049aSZach Atkins    return TestSuite(test, test_cases)
8181b16049aSZach Atkins
8191b16049aSZach Atkins
820*681c4c48SJeremy L Thompsondef write_junit_xml(test_suite: TestSuite, batch: str = '') -> None:
8211b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
8221b16049aSZach Atkins
8231b16049aSZach Atkins    Args:
8241b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
8251b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
8261b16049aSZach Atkins    """
827*681c4c48SJeremy L Thompson    output_file = Path('build') / (f'{test_suite.name}{batch}.junit')
8281b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
8291b16049aSZach Atkins
8301b16049aSZach Atkins
8311b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
8321b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
8331b16049aSZach Atkins
8341b16049aSZach Atkins    Args:
8351b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
8361b16049aSZach Atkins
8371b16049aSZach Atkins    Returns:
8381b16049aSZach Atkins        bool: True if any test cases failed
8391b16049aSZach Atkins    """
8401b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
841