xref: /libCEED/tests/junit_common.py (revision 69ef23b6c4e7d5a45728d9ea1580ce79ef1002a6)
11b16049aSZach Atkinsfrom abc import ABC, abstractmethod
21b16049aSZach Atkinsimport argparse
3*69ef23b6SZach Atkinsimport csv
41b16049aSZach Atkinsfrom dataclasses import dataclass, field
51b16049aSZach Atkinsimport difflib
61b16049aSZach Atkinsfrom enum import Enum
71b16049aSZach Atkinsfrom math import isclose
81b16049aSZach Atkinsimport os
91b16049aSZach Atkinsfrom pathlib import Path
101b16049aSZach Atkinsimport re
111b16049aSZach Atkinsimport subprocess
1219868e18SZach Atkinsimport multiprocessing as mp
1319868e18SZach Atkinsfrom itertools import product
141b16049aSZach Atkinsimport sys
151b16049aSZach Atkinsimport time
1678cb100bSJames Wrightfrom typing import Optional, Tuple, List
171b16049aSZach Atkins
181b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
191b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
201b16049aSZach Atkins
211b16049aSZach Atkins
221b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action):
231b16049aSZach Atkins    """Action to convert input values to lower case prior to converting to an Enum type"""
241b16049aSZach Atkins
251b16049aSZach Atkins    def __init__(self, option_strings, dest, type, default, **kwargs):
261b16049aSZach Atkins        if not (issubclass(type, Enum) and issubclass(type, str)):
271b16049aSZach Atkins            raise ValueError(f"{type} must be a StrEnum or str and Enum")
281b16049aSZach Atkins        # store provided enum type
291b16049aSZach Atkins        self.enum_type = type
301b16049aSZach Atkins        if isinstance(default, str):
311b16049aSZach Atkins            default = self.enum_type(default.lower())
321b16049aSZach Atkins        else:
331b16049aSZach Atkins            default = [self.enum_type(v.lower()) for v in default]
341b16049aSZach Atkins        # prevent automatic type conversion
351b16049aSZach Atkins        super().__init__(option_strings, dest, default=default, **kwargs)
361b16049aSZach Atkins
371b16049aSZach Atkins    def __call__(self, parser, namespace, values, option_string=None):
381b16049aSZach Atkins        if isinstance(values, str):
391b16049aSZach Atkins            values = self.enum_type(values.lower())
401b16049aSZach Atkins        else:
411b16049aSZach Atkins            values = [self.enum_type(v.lower()) for v in values]
421b16049aSZach Atkins        setattr(namespace, self.dest, values)
431b16049aSZach Atkins
441b16049aSZach Atkins
451b16049aSZach Atkins@dataclass
461b16049aSZach Atkinsclass TestSpec:
471b16049aSZach Atkins    """Dataclass storing information about a single test case"""
481b16049aSZach Atkins    name: str
498938a869SZach Atkins    only: List = field(default_factory=list)
508938a869SZach Atkins    args: List = field(default_factory=list)
511b16049aSZach Atkins
521b16049aSZach Atkins
531b16049aSZach Atkinsclass RunMode(str, Enum):
541b16049aSZach Atkins    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
551b16049aSZach Atkins    __str__ = str.__str__
561b16049aSZach Atkins    __format__ = str.__format__
571b16049aSZach Atkins    TAP: str = 'tap'
581b16049aSZach Atkins    JUNIT: str = 'junit'
591b16049aSZach Atkins
601b16049aSZach Atkins
611b16049aSZach Atkinsclass SuiteSpec(ABC):
621b16049aSZach Atkins    """Abstract Base Class defining the required interface for running a test suite"""
631b16049aSZach Atkins    @abstractmethod
641b16049aSZach Atkins    def get_source_path(self, test: str) -> Path:
651b16049aSZach Atkins        """Compute path to test source file
661b16049aSZach Atkins
671b16049aSZach Atkins        Args:
681b16049aSZach Atkins            test (str): Name of test
691b16049aSZach Atkins
701b16049aSZach Atkins        Returns:
711b16049aSZach Atkins            Path: Path to source file
721b16049aSZach Atkins        """
731b16049aSZach Atkins        raise NotImplementedError
741b16049aSZach Atkins
751b16049aSZach Atkins    @abstractmethod
761b16049aSZach Atkins    def get_run_path(self, test: str) -> Path:
771b16049aSZach Atkins        """Compute path to built test executable file
781b16049aSZach Atkins
791b16049aSZach Atkins        Args:
801b16049aSZach Atkins            test (str): Name of test
811b16049aSZach Atkins
821b16049aSZach Atkins        Returns:
831b16049aSZach Atkins            Path: Path to test executable
841b16049aSZach Atkins        """
851b16049aSZach Atkins        raise NotImplementedError
861b16049aSZach Atkins
871b16049aSZach Atkins    @abstractmethod
881b16049aSZach Atkins    def get_output_path(self, test: str, output_file: str) -> Path:
891b16049aSZach Atkins        """Compute path to expected output file
901b16049aSZach Atkins
911b16049aSZach Atkins        Args:
921b16049aSZach Atkins            test (str): Name of test
931b16049aSZach Atkins            output_file (str): File name of output file
941b16049aSZach Atkins
951b16049aSZach Atkins        Returns:
961b16049aSZach Atkins            Path: Path to expected output file
971b16049aSZach Atkins        """
981b16049aSZach Atkins        raise NotImplementedError
991b16049aSZach Atkins
1001b16049aSZach Atkins    def post_test_hook(self, test: str, spec: TestSpec) -> None:
1011b16049aSZach Atkins        """Function callback ran after each test case
1021b16049aSZach Atkins
1031b16049aSZach Atkins        Args:
1041b16049aSZach Atkins            test (str): Name of test
1051b16049aSZach Atkins            spec (TestSpec): Test case specification
1061b16049aSZach Atkins        """
1071b16049aSZach Atkins        pass
1081b16049aSZach Atkins
1091b16049aSZach Atkins    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
1101b16049aSZach Atkins        """Check if a test case should be skipped prior to running, returning the reason for skipping
1111b16049aSZach Atkins
1121b16049aSZach Atkins        Args:
1131b16049aSZach Atkins            test (str): Name of test
1141b16049aSZach Atkins            spec (TestSpec): Test case specification
1151b16049aSZach Atkins            resource (str): libCEED backend
1161b16049aSZach Atkins            nproc (int): Number of MPI processes to use when running test case
1171b16049aSZach Atkins
1181b16049aSZach Atkins        Returns:
1191b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if test case should not be skipped
1201b16049aSZach Atkins        """
1211b16049aSZach Atkins        return None
1221b16049aSZach Atkins
1231b16049aSZach Atkins    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
1241b16049aSZach Atkins        """Check if a test case should be allowed to fail, based on its stderr output
1251b16049aSZach Atkins
1261b16049aSZach Atkins        Args:
1271b16049aSZach Atkins            test (str): Name of test
1281b16049aSZach Atkins            spec (TestSpec): Test case specification
1291b16049aSZach Atkins            resource (str): libCEED backend
1301b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1311b16049aSZach Atkins
1321b16049aSZach Atkins        Returns:
13319868e18SZach Atkins            Optional[str]: Skip reason, or `None` if unexpected error
1341b16049aSZach Atkins        """
1351b16049aSZach Atkins        return None
1361b16049aSZach Atkins
13778cb100bSJames Wright    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Tuple[str, bool]:
1381b16049aSZach Atkins        """Check whether a test case is expected to fail and if it failed expectedly
1391b16049aSZach Atkins
1401b16049aSZach Atkins        Args:
1411b16049aSZach Atkins            test (str): Name of test
1421b16049aSZach Atkins            spec (TestSpec): Test case specification
1431b16049aSZach Atkins            resource (str): libCEED backend
1441b16049aSZach Atkins            stderr (str): Standard error output from test case execution
1451b16049aSZach Atkins
1461b16049aSZach Atkins        Returns:
1471b16049aSZach Atkins            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
1481b16049aSZach Atkins        """
1491b16049aSZach Atkins        return '', True
1501b16049aSZach Atkins
1511b16049aSZach Atkins    def check_allowed_stdout(self, test: str) -> bool:
1521b16049aSZach Atkins        """Check whether a test is allowed to print console output
1531b16049aSZach Atkins
1541b16049aSZach Atkins        Args:
1551b16049aSZach Atkins            test (str): Name of test
1561b16049aSZach Atkins
1571b16049aSZach Atkins        Returns:
1581b16049aSZach Atkins            bool: True if the test is allowed to print console output
1591b16049aSZach Atkins        """
1601b16049aSZach Atkins        return False
1611b16049aSZach Atkins
1621b16049aSZach Atkins
1631b16049aSZach Atkinsdef has_cgnsdiff() -> bool:
1641b16049aSZach Atkins    """Check whether `cgnsdiff` is an executable program in the current environment
1651b16049aSZach Atkins
1661b16049aSZach Atkins    Returns:
1671b16049aSZach Atkins        bool: True if `cgnsdiff` is found
1681b16049aSZach Atkins    """
1691b16049aSZach Atkins    my_env: dict = os.environ.copy()
1701b16049aSZach Atkins    proc = subprocess.run('cgnsdiff',
1711b16049aSZach Atkins                          shell=True,
1721b16049aSZach Atkins                          stdout=subprocess.PIPE,
1731b16049aSZach Atkins                          stderr=subprocess.PIPE,
1741b16049aSZach Atkins                          env=my_env)
1751b16049aSZach Atkins    return 'not found' not in proc.stderr.decode('utf-8')
1761b16049aSZach Atkins
1771b16049aSZach Atkins
17878cb100bSJames Wrightdef contains_any(base: str, substrings: List[str]) -> bool:
1791b16049aSZach Atkins    """Helper function, checks if any of the substrings are included in the base string
1801b16049aSZach Atkins
1811b16049aSZach Atkins    Args:
1821b16049aSZach Atkins        base (str): Base string to search in
1838938a869SZach Atkins        substrings (List[str]): List of potential substrings
1841b16049aSZach Atkins
1851b16049aSZach Atkins    Returns:
1861b16049aSZach Atkins        bool: True if any substrings are included in base string
1871b16049aSZach Atkins    """
1881b16049aSZach Atkins    return any((sub in base for sub in substrings))
1891b16049aSZach Atkins
1901b16049aSZach Atkins
19178cb100bSJames Wrightdef startswith_any(base: str, prefixes: List[str]) -> bool:
1921b16049aSZach Atkins    """Helper function, checks if the base string is prefixed by any of `prefixes`
1931b16049aSZach Atkins
1941b16049aSZach Atkins    Args:
1951b16049aSZach Atkins        base (str): Base string to search
1968938a869SZach Atkins        prefixes (List[str]): List of potential prefixes
1971b16049aSZach Atkins
1981b16049aSZach Atkins    Returns:
1991b16049aSZach Atkins        bool: True if base string is prefixed by any of the prefixes
2001b16049aSZach Atkins    """
2011b16049aSZach Atkins    return any((base.startswith(prefix) for prefix in prefixes))
2021b16049aSZach Atkins
2031b16049aSZach Atkins
2041b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec:
2051b16049aSZach Atkins    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
2061b16049aSZach Atkins
2071b16049aSZach Atkins    Args:
2081b16049aSZach Atkins        line (str): String containing TESTARGS specification and CLI arguments
2091b16049aSZach Atkins
2101b16049aSZach Atkins    Returns:
2111b16049aSZach Atkins        TestSpec: Parsed specification of test case
2121b16049aSZach Atkins    """
21378cb100bSJames Wright    args: List[str] = re.findall("(?:\".*?\"|\\S)+", line.strip())
2141b16049aSZach Atkins    if args[0] == 'TESTARGS':
2151b16049aSZach Atkins        return TestSpec(name='', args=args[1:])
2161b16049aSZach Atkins    raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')]
2171b16049aSZach Atkins    # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'}
2181b16049aSZach Atkins    test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)])
219f85e4a7bSJeremy L Thompson    name: str = test_args.get('name', '')
22078cb100bSJames Wright    constraints: List[str] = test_args['only'].split(',') if 'only' in test_args else []
2211b16049aSZach Atkins    if len(args) > 1:
222f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints, args=args[1:])
2231b16049aSZach Atkins    else:
224f85e4a7bSJeremy L Thompson        return TestSpec(name=name, only=constraints)
2251b16049aSZach Atkins
2261b16049aSZach Atkins
22778cb100bSJames Wrightdef get_test_args(source_file: Path) -> List[TestSpec]:
2281b16049aSZach Atkins    """Parse all test cases from a given source file
2291b16049aSZach Atkins
2301b16049aSZach Atkins    Args:
2311b16049aSZach Atkins        source_file (Path): Path to source file
2321b16049aSZach Atkins
2331b16049aSZach Atkins    Raises:
2341b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
2351b16049aSZach Atkins
2361b16049aSZach Atkins    Returns:
2378938a869SZach Atkins        List[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
2381b16049aSZach Atkins    """
2391b16049aSZach Atkins    comment_str: str = ''
2408c81f8b0SPeter Munch    if source_file.suffix in ['.c', '.cc', '.cpp']:
2411b16049aSZach Atkins        comment_str = '//'
2421b16049aSZach Atkins    elif source_file.suffix in ['.py']:
2431b16049aSZach Atkins        comment_str = '#'
2441b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
2451b16049aSZach Atkins        comment_str = 'C_'
2461b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
2471b16049aSZach Atkins        comment_str = '! '
2481b16049aSZach Atkins    else:
2491b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
2501b16049aSZach Atkins
2511b16049aSZach Atkins    return [parse_test_line(line.strip(comment_str))
2521b16049aSZach Atkins            for line in source_file.read_text().splitlines()
2531b16049aSZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
2541b16049aSZach Atkins
2551b16049aSZach Atkins
2561b16049aSZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2) -> str:
2571b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
2581b16049aSZach Atkins
2591b16049aSZach Atkins    Args:
2601b16049aSZach Atkins        test_csv (Path): Path to output CSV results
2611b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
2621b16049aSZach Atkins        zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10.
2631b16049aSZach Atkins        rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2.
2641b16049aSZach Atkins
2651b16049aSZach Atkins    Returns:
2661b16049aSZach Atkins        str: Diff output between result and expected CSVs
2671b16049aSZach Atkins    """
26878cb100bSJames Wright    test_lines: List[str] = test_csv.read_text().splitlines()
26978cb100bSJames Wright    true_lines: List[str] = true_csv.read_text().splitlines()
270*69ef23b6SZach Atkins    # Files should not be empty
271*69ef23b6SZach Atkins    if len(test_lines) == 0:
272*69ef23b6SZach Atkins        return f'No lines found in test output {test_csv}'
273*69ef23b6SZach Atkins    if len(true_lines) == 0:
274*69ef23b6SZach Atkins        return f'No lines found in test source {true_csv}'
2751b16049aSZach Atkins
276*69ef23b6SZach Atkins    test_reader: csv.DictReader = csv.DictReader(test_lines)
277*69ef23b6SZach Atkins    true_reader: csv.DictReader = csv.DictReader(true_lines)
278*69ef23b6SZach Atkins    if test_reader.fieldnames != true_reader.fieldnames:
2791b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
2801b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
2811b16049aSZach Atkins
282*69ef23b6SZach Atkins    if len(test_lines) != len(true_lines):
283*69ef23b6SZach Atkins        return f'Number of lines in {test_csv} and {true_csv} do not match'
28478cb100bSJames Wright    diff_lines: List[str] = list()
285*69ef23b6SZach Atkins    for test_line, true_line in zip(test_reader, true_reader):
286*69ef23b6SZach Atkins        for key in test_reader.fieldnames:
287*69ef23b6SZach Atkins            # Check if the value is numeric
288*69ef23b6SZach Atkins            try:
289*69ef23b6SZach Atkins                true_val: float = float(true_line[key])
290*69ef23b6SZach Atkins                test_val: float = float(test_line[key])
2911b16049aSZach Atkins                true_zero: bool = abs(true_val) < zero_tol
2921b16049aSZach Atkins                test_zero: bool = abs(test_val) < zero_tol
2931b16049aSZach Atkins                fail: bool = False
2941b16049aSZach Atkins                if true_zero:
2951b16049aSZach Atkins                    fail = not test_zero
2961b16049aSZach Atkins                else:
2971b16049aSZach Atkins                    fail = not isclose(test_val, true_val, rel_tol=rel_tol)
2981b16049aSZach Atkins                if fail:
299*69ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_val}, got: {test_val}')
300*69ef23b6SZach Atkins            except ValueError:
301*69ef23b6SZach Atkins                if test_line[key] != true_line[key]:
302*69ef23b6SZach Atkins                    diff_lines.append(f'column: {key}, expected: {true_line[key]}, got: {test_line[key]}')
303*69ef23b6SZach Atkins
3041b16049aSZach Atkins    return '\n'.join(diff_lines)
3051b16049aSZach Atkins
3061b16049aSZach Atkins
3071b16049aSZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, tolerance: float = 1e-12) -> str:
3081b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
3091b16049aSZach Atkins
3101b16049aSZach Atkins    Args:
3111b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
3121b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
3131b16049aSZach Atkins        tolerance (float, optional): Tolerance for comparing floating-point values
3141b16049aSZach Atkins
3151b16049aSZach Atkins    Returns:
3161b16049aSZach Atkins        str: Diff output between result and expected CGNS files
3171b16049aSZach Atkins    """
3181b16049aSZach Atkins    my_env: dict = os.environ.copy()
3191b16049aSZach Atkins
32078cb100bSJames Wright    run_args: List[str] = ['cgnsdiff', '-d', '-t', f'{tolerance}', str(test_cgns), str(true_cgns)]
3211b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
3221b16049aSZach Atkins                          shell=True,
3231b16049aSZach Atkins                          stdout=subprocess.PIPE,
3241b16049aSZach Atkins                          stderr=subprocess.PIPE,
3251b16049aSZach Atkins                          env=my_env)
3261b16049aSZach Atkins
3271b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
3281b16049aSZach Atkins
3291b16049aSZach Atkins
330e17e35bbSJames Wrightdef test_case_output_string(test_case: TestCase, spec: TestSpec, mode: RunMode,
331e17e35bbSJames Wright                            backend: str, test: str, index: int) -> str:
332e17e35bbSJames Wright    output_str = ''
333e17e35bbSJames Wright    if mode is RunMode.TAP:
334e17e35bbSJames Wright        # print incremental output if TAP mode
335e17e35bbSJames Wright        if test_case.is_skipped():
336e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend} # SKIP {test_case.skipped[0]["message"]}\n'
337e17e35bbSJames Wright        elif test_case.is_failure() or test_case.is_error():
338e17e35bbSJames Wright            output_str += f'    not ok {index} - {spec.name}, {backend}\n'
339e17e35bbSJames Wright        else:
340e17e35bbSJames Wright            output_str += f'    ok {index} - {spec.name}, {backend}\n'
341e17e35bbSJames Wright        output_str += f'      ---\n'
342e17e35bbSJames Wright        if spec.only:
343e17e35bbSJames Wright            output_str += f'      only: {",".join(spec.only)}\n'
344e17e35bbSJames Wright        output_str += f'      args: {test_case.args}\n'
345e17e35bbSJames Wright        if test_case.is_error():
346e17e35bbSJames Wright            output_str += f'      error: {test_case.errors[0]["message"]}\n'
347e17e35bbSJames Wright        if test_case.is_failure():
348e17e35bbSJames Wright            output_str += f'      num_failures: {len(test_case.failures)}\n'
349e17e35bbSJames Wright            for i, failure in enumerate(test_case.failures):
350e17e35bbSJames Wright                output_str += f'      failure_{i}: {failure["message"]}\n'
351e17e35bbSJames Wright                output_str += f'        message: {failure["message"]}\n'
352e17e35bbSJames Wright                if failure["output"]:
353e17e35bbSJames Wright                    out = failure["output"].strip().replace('\n', '\n          ')
354e17e35bbSJames Wright                    output_str += f'        output: |\n          {out}\n'
355e17e35bbSJames Wright        output_str += f'      ...\n'
356e17e35bbSJames Wright    else:
357e17e35bbSJames Wright        # print error or failure information if JUNIT mode
358e17e35bbSJames Wright        if test_case.is_error() or test_case.is_failure():
359e17e35bbSJames Wright            output_str += f'Test: {test} {spec.name}\n'
360e17e35bbSJames Wright            output_str += f'  $ {test_case.args}\n'
361e17e35bbSJames Wright            if test_case.is_error():
362e17e35bbSJames Wright                output_str += 'ERROR: {}\n'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip())
363e17e35bbSJames Wright                output_str += 'Output: \n{}\n'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip())
364e17e35bbSJames Wright            if test_case.is_failure():
365e17e35bbSJames Wright                for failure in test_case.failures:
366e17e35bbSJames Wright                    output_str += 'FAIL: {}\n'.format((failure['message'] or 'NO MESSAGE').strip())
367e17e35bbSJames Wright                    output_str += 'Output: \n{}\n'.format((failure['output'] or 'NO MESSAGE').strip())
368e17e35bbSJames Wright    return output_str
369e17e35bbSJames Wright
370e17e35bbSJames Wright
37119868e18SZach Atkinsdef run_test(index: int, test: str, spec: TestSpec, backend: str,
37219868e18SZach Atkins             mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestCase:
37319868e18SZach Atkins    """Run a single test case and backend combination
3741b16049aSZach Atkins
3751b16049aSZach Atkins    Args:
3768938a869SZach Atkins        index (int): Index of backend for current spec
37719868e18SZach Atkins        test (str): Path to test
37819868e18SZach Atkins        spec (TestSpec): Specification of test case
37919868e18SZach Atkins        backend (str): CEED backend
38019868e18SZach Atkins        mode (RunMode): Output mode
38119868e18SZach Atkins        nproc (int): Number of MPI processes to use when running test case
38219868e18SZach Atkins        suite_spec (SuiteSpec): Specification of test suite
3831b16049aSZach Atkins
3841b16049aSZach Atkins    Returns:
38519868e18SZach Atkins        TestCase: Test case result
3861b16049aSZach Atkins    """
3871b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
3888938a869SZach Atkins    run_args: List = [f'{suite_spec.get_run_path(test)}', *map(str, spec.args)]
3891b16049aSZach Atkins
3901b16049aSZach Atkins    if '{ceed_resource}' in run_args:
39119868e18SZach Atkins        run_args[run_args.index('{ceed_resource}')] = backend
3928938a869SZach Atkins    for i, arg in enumerate(run_args):
3938938a869SZach Atkins        if '{ceed_resource}' in arg:
3948938a869SZach Atkins            run_args[i] = arg.replace('{ceed_resource}', backend.replace('/', '-'))
3951b16049aSZach Atkins    if '{nproc}' in run_args:
3961b16049aSZach Atkins        run_args[run_args.index('{nproc}')] = f'{nproc}'
3971b16049aSZach Atkins    elif nproc > 1 and source_path.suffix != '.py':
3981b16049aSZach Atkins        run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
3991b16049aSZach Atkins
4001b16049aSZach Atkins    # run test
40119868e18SZach Atkins    skip_reason: str = suite_spec.check_pre_skip(test, spec, backend, nproc)
4021b16049aSZach Atkins    if skip_reason:
40319868e18SZach Atkins        test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
4041b16049aSZach Atkins                                       elapsed_sec=0,
4051b16049aSZach Atkins                                       timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
4061b16049aSZach Atkins                                       stdout='',
4078938a869SZach Atkins                                       stderr='',
4088938a869SZach Atkins                                       category=spec.name,)
4091b16049aSZach Atkins        test_case.add_skipped_info(skip_reason)
4101b16049aSZach Atkins    else:
4111b16049aSZach Atkins        start: float = time.time()
4121b16049aSZach Atkins        proc = subprocess.run(' '.join(str(arg) for arg in run_args),
4131b16049aSZach Atkins                              shell=True,
4141b16049aSZach Atkins                              stdout=subprocess.PIPE,
4151b16049aSZach Atkins                              stderr=subprocess.PIPE,
4161b16049aSZach Atkins                              env=my_env)
4171b16049aSZach Atkins
41819868e18SZach Atkins        test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {backend}',
4191b16049aSZach Atkins                             classname=source_path.parent,
4201b16049aSZach Atkins                             elapsed_sec=time.time() - start,
4211b16049aSZach Atkins                             timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
4221b16049aSZach Atkins                             stdout=proc.stdout.decode('utf-8'),
4231b16049aSZach Atkins                             stderr=proc.stderr.decode('utf-8'),
4248938a869SZach Atkins                             allow_multiple_subelements=True,
4258938a869SZach Atkins                             category=spec.name,)
42678cb100bSJames Wright        ref_csvs: List[Path] = []
4278938a869SZach Atkins        output_files: List[str] = [arg for arg in run_args if 'ascii:' in arg]
42897fab443SJeremy L Thompson        if output_files:
4291b16049aSZach Atkins            ref_csvs = [suite_spec.get_output_path(test, file.split('ascii:')[-1]) for file in output_files]
43078cb100bSJames Wright        ref_cgns: List[Path] = []
4318938a869SZach Atkins        output_files = [arg for arg in run_args if 'cgns:' in arg]
43297fab443SJeremy L Thompson        if output_files:
4331b16049aSZach Atkins            ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
4341b16049aSZach Atkins        ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
4351b16049aSZach Atkins        suite_spec.post_test_hook(test, spec)
4361b16049aSZach Atkins
4371b16049aSZach Atkins    # check allowed failures
4381b16049aSZach Atkins    if not test_case.is_skipped() and test_case.stderr:
43919868e18SZach Atkins        skip_reason: str = suite_spec.check_post_skip(test, spec, backend, test_case.stderr)
4401b16049aSZach Atkins        if skip_reason:
4411b16049aSZach Atkins            test_case.add_skipped_info(skip_reason)
4421b16049aSZach Atkins
4431b16049aSZach Atkins    # check required failures
4441b16049aSZach Atkins    if not test_case.is_skipped():
4452fee3251SSebastian Grimberg        required_message, did_fail = suite_spec.check_required_failure(
44619868e18SZach Atkins            test, spec, backend, test_case.stderr)
4471b16049aSZach Atkins        if required_message and did_fail:
4481b16049aSZach Atkins            test_case.status = f'fails with required: {required_message}'
4491b16049aSZach Atkins        elif required_message:
4501b16049aSZach Atkins            test_case.add_failure_info(f'required failure missing: {required_message}')
4511b16049aSZach Atkins
4521b16049aSZach Atkins    # classify other results
4531b16049aSZach Atkins    if not test_case.is_skipped() and not test_case.status:
4541b16049aSZach Atkins        if test_case.stderr:
4551b16049aSZach Atkins            test_case.add_failure_info('stderr', test_case.stderr)
4561b16049aSZach Atkins        if proc.returncode != 0:
4571b16049aSZach Atkins            test_case.add_error_info(f'returncode = {proc.returncode}')
4581b16049aSZach Atkins        if ref_stdout.is_file():
4591b16049aSZach Atkins            diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
4601b16049aSZach Atkins                                             test_case.stdout.splitlines(keepends=True),
4611b16049aSZach Atkins                                             fromfile=str(ref_stdout),
4621b16049aSZach Atkins                                             tofile='New'))
4631b16049aSZach Atkins            if diff:
4641b16049aSZach Atkins                test_case.add_failure_info('stdout', output=''.join(diff))
4651b16049aSZach Atkins        elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
4661b16049aSZach Atkins            test_case.add_failure_info('stdout', output=test_case.stdout)
4671b16049aSZach Atkins        # expected CSV output
4681b16049aSZach Atkins        for ref_csv in ref_csvs:
4698938a869SZach Atkins            csv_name = ref_csv.name
4708938a869SZach Atkins            if not ref_csv.is_file():
4718938a869SZach Atkins                # remove _{ceed_backend} from path name
4728938a869SZach Atkins                ref_csv = (ref_csv.parent / ref_csv.name.rsplit('_', 1)[0]).with_suffix('.csv')
4731b16049aSZach Atkins            if not ref_csv.is_file():
4741b16049aSZach Atkins                test_case.add_failure_info('csv', output=f'{ref_csv} not found')
4751b16049aSZach Atkins            else:
4768938a869SZach Atkins                diff: str = diff_csv(Path.cwd() / csv_name, ref_csv)
4771b16049aSZach Atkins                if diff:
4781b16049aSZach Atkins                    test_case.add_failure_info('csv', output=diff)
4791b16049aSZach Atkins                else:
4808938a869SZach Atkins                    (Path.cwd() / csv_name).unlink()
4811b16049aSZach Atkins        # expected CGNS output
4821b16049aSZach Atkins        for ref_cgn in ref_cgns:
4838938a869SZach Atkins            cgn_name = ref_cgn.name
4848938a869SZach Atkins            if not ref_cgn.is_file():
4858938a869SZach Atkins                # remove _{ceed_backend} from path name
4868938a869SZach Atkins                ref_cgn = (ref_cgn.parent / ref_cgn.name.rsplit('_', 1)[0]).with_suffix('.cgns')
4871b16049aSZach Atkins            if not ref_cgn.is_file():
4881b16049aSZach Atkins                test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
4891b16049aSZach Atkins            else:
4908938a869SZach Atkins                diff = diff_cgns(Path.cwd() / cgn_name, ref_cgn)
4911b16049aSZach Atkins                if diff:
4921b16049aSZach Atkins                    test_case.add_failure_info('cgns', output=diff)
4931b16049aSZach Atkins                else:
4948938a869SZach Atkins                    (Path.cwd() / cgn_name).unlink()
4951b16049aSZach Atkins
4961b16049aSZach Atkins    # store result
4971b16049aSZach Atkins    test_case.args = ' '.join(str(arg) for arg in run_args)
498e17e35bbSJames Wright    output_str = test_case_output_string(test_case, spec, mode, backend, test, index)
49919868e18SZach Atkins
50019868e18SZach Atkins    return test_case, output_str
50119868e18SZach Atkins
50219868e18SZach Atkins
50319868e18SZach Atkinsdef init_process():
50419868e18SZach Atkins    """Initialize multiprocessing process"""
50519868e18SZach Atkins    # set up error handler
50619868e18SZach Atkins    global my_env
50719868e18SZach Atkins    my_env = os.environ.copy()
50819868e18SZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
50919868e18SZach Atkins
51019868e18SZach Atkins
51178cb100bSJames Wrightdef run_tests(test: str, ceed_backends: List[str], mode: RunMode, nproc: int,
51219868e18SZach Atkins              suite_spec: SuiteSpec, pool_size: int = 1) -> TestSuite:
51319868e18SZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
51419868e18SZach Atkins
51519868e18SZach Atkins    Args:
51619868e18SZach Atkins        test (str): Name of test
5178938a869SZach Atkins        ceed_backends (List[str]): List of libCEED backends
51819868e18SZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
51919868e18SZach Atkins        nproc (int): Number of MPI processes to use when running each test case
52019868e18SZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
52119868e18SZach Atkins        pool_size (int, optional): Number of processes to use when running tests in parallel. Defaults to 1.
52219868e18SZach Atkins
52319868e18SZach Atkins    Returns:
52419868e18SZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
52519868e18SZach Atkins    """
52678cb100bSJames Wright    test_specs: List[TestSpec] = get_test_args(suite_spec.get_source_path(test))
52719868e18SZach Atkins    if mode is RunMode.TAP:
5288938a869SZach Atkins        print('TAP version 13')
5298938a869SZach Atkins        print(f'1..{len(test_specs)}')
53019868e18SZach Atkins
53119868e18SZach Atkins    with mp.Pool(processes=pool_size, initializer=init_process) as pool:
5328938a869SZach Atkins        async_outputs: List[List[mp.AsyncResult]] = [
5338938a869SZach Atkins            [pool.apply_async(run_test, (i, test, spec, backend, mode, nproc, suite_spec))
5348938a869SZach Atkins             for (i, backend) in enumerate(ceed_backends, start=1)]
5358938a869SZach Atkins            for spec in test_specs
5368938a869SZach Atkins        ]
53719868e18SZach Atkins
53819868e18SZach Atkins        test_cases = []
5398938a869SZach Atkins        for (i, subtest) in enumerate(async_outputs, start=1):
5408938a869SZach Atkins            is_new_subtest = True
5418938a869SZach Atkins            subtest_ok = True
5428938a869SZach Atkins            for async_output in subtest:
54319868e18SZach Atkins                test_case, print_output = async_output.get()
54419868e18SZach Atkins                test_cases.append(test_case)
5458938a869SZach Atkins                if is_new_subtest and mode == RunMode.TAP:
5468938a869SZach Atkins                    is_new_subtest = False
5478938a869SZach Atkins                    print(f'# Subtest: {test_case.category}')
5488938a869SZach Atkins                    print(f'    1..{len(ceed_backends)}')
54919868e18SZach Atkins                print(print_output, end='')
5508938a869SZach Atkins                if test_case.is_failure() or test_case.is_error():
5518938a869SZach Atkins                    subtest_ok = False
5528938a869SZach Atkins            if mode == RunMode.TAP:
5538938a869SZach Atkins                print(f'{"" if subtest_ok else "not "}ok {i} - {test_case.category}')
5541b16049aSZach Atkins
5551b16049aSZach Atkins    return TestSuite(test, test_cases)
5561b16049aSZach Atkins
5571b16049aSZach Atkins
5581b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
5591b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
5601b16049aSZach Atkins
5611b16049aSZach Atkins    Args:
5621b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
5631b16049aSZach Atkins        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
5641b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
5651b16049aSZach Atkins    """
5661b16049aSZach Atkins    output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
5671b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
5681b16049aSZach Atkins
5691b16049aSZach Atkins
5701b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
5711b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
5721b16049aSZach Atkins
5731b16049aSZach Atkins    Args:
5741b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
5751b16049aSZach Atkins
5761b16049aSZach Atkins    Returns:
5771b16049aSZach Atkins        bool: True if any test cases failed
5781b16049aSZach Atkins    """
5791b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
580