xref: /libCEED/tests/junit_common.py (revision 1b16049a10e17ae70b657db3a7ebdc4091aecb17)
1*1b16049aSZach Atkinsfrom abc import ABC, abstractmethod
2*1b16049aSZach Atkinsimport argparse
3*1b16049aSZach Atkinsfrom dataclasses import dataclass, field
4*1b16049aSZach Atkinsimport difflib
5*1b16049aSZach Atkinsfrom enum import Enum
6*1b16049aSZach Atkinsfrom math import isclose
7*1b16049aSZach Atkinsimport os
8*1b16049aSZach Atkinsfrom pathlib import Path
9*1b16049aSZach Atkinsimport re
10*1b16049aSZach Atkinsimport subprocess
11*1b16049aSZach Atkinsimport sys
12*1b16049aSZach Atkinsimport time
13*1b16049aSZach Atkinsfrom typing import Optional
14*1b16049aSZach Atkins
15*1b16049aSZach Atkinssys.path.insert(0, str(Path(__file__).parent / "junit-xml"))
16*1b16049aSZach Atkinsfrom junit_xml import TestCase, TestSuite, to_xml_report_string  # nopep8
17*1b16049aSZach Atkins
18*1b16049aSZach Atkins
19*1b16049aSZach Atkinsclass CaseInsensitiveEnumAction(argparse.Action):
20*1b16049aSZach Atkins    """Action to convert input values to lower case prior to converting to an Enum type"""
21*1b16049aSZach Atkins
22*1b16049aSZach Atkins    def __init__(self, option_strings, dest, type, default, **kwargs):
23*1b16049aSZach Atkins        if not (issubclass(type, Enum) and issubclass(type, str)):
24*1b16049aSZach Atkins            raise ValueError(f"{type} must be a StrEnum or str and Enum")
25*1b16049aSZach Atkins        # store provided enum type
26*1b16049aSZach Atkins        self.enum_type = type
27*1b16049aSZach Atkins        if isinstance(default, str):
28*1b16049aSZach Atkins            default = self.enum_type(default.lower())
29*1b16049aSZach Atkins        else:
30*1b16049aSZach Atkins            default = [self.enum_type(v.lower()) for v in default]
31*1b16049aSZach Atkins        # prevent automatic type conversion
32*1b16049aSZach Atkins        super().__init__(option_strings, dest, default=default, **kwargs)
33*1b16049aSZach Atkins
34*1b16049aSZach Atkins    def __call__(self, parser, namespace, values, option_string=None):
35*1b16049aSZach Atkins        if isinstance(values, str):
36*1b16049aSZach Atkins            values = self.enum_type(values.lower())
37*1b16049aSZach Atkins        else:
38*1b16049aSZach Atkins            values = [self.enum_type(v.lower()) for v in values]
39*1b16049aSZach Atkins        setattr(namespace, self.dest, values)
40*1b16049aSZach Atkins
41*1b16049aSZach Atkins
42*1b16049aSZach Atkins@dataclass
43*1b16049aSZach Atkinsclass TestSpec:
44*1b16049aSZach Atkins    """Dataclass storing information about a single test case"""
45*1b16049aSZach Atkins    name: str
46*1b16049aSZach Atkins    only: list = field(default_factory=list)
47*1b16049aSZach Atkins    args: list = field(default_factory=list)
48*1b16049aSZach Atkins
49*1b16049aSZach Atkins
50*1b16049aSZach Atkinsclass RunMode(str, Enum):
51*1b16049aSZach Atkins    """Enumeration of run modes, either `RunMode.TAP` or `RunMode.JUNIT`"""
52*1b16049aSZach Atkins    __str__ = str.__str__
53*1b16049aSZach Atkins    __format__ = str.__format__
54*1b16049aSZach Atkins    TAP: str = 'tap'
55*1b16049aSZach Atkins    JUNIT: str = 'junit'
56*1b16049aSZach Atkins
57*1b16049aSZach Atkins
58*1b16049aSZach Atkinsclass SuiteSpec(ABC):
59*1b16049aSZach Atkins    """Abstract Base Class defining the required interface for running a test suite"""
60*1b16049aSZach Atkins    @abstractmethod
61*1b16049aSZach Atkins    def get_source_path(self, test: str) -> Path:
62*1b16049aSZach Atkins        """Compute path to test source file
63*1b16049aSZach Atkins
64*1b16049aSZach Atkins        Args:
65*1b16049aSZach Atkins            test (str): Name of test
66*1b16049aSZach Atkins
67*1b16049aSZach Atkins        Returns:
68*1b16049aSZach Atkins            Path: Path to source file
69*1b16049aSZach Atkins        """
70*1b16049aSZach Atkins        raise NotImplementedError
71*1b16049aSZach Atkins
72*1b16049aSZach Atkins    @abstractmethod
73*1b16049aSZach Atkins    def get_run_path(self, test: str) -> Path:
74*1b16049aSZach Atkins        """Compute path to built test executable file
75*1b16049aSZach Atkins
76*1b16049aSZach Atkins        Args:
77*1b16049aSZach Atkins            test (str): Name of test
78*1b16049aSZach Atkins
79*1b16049aSZach Atkins        Returns:
80*1b16049aSZach Atkins            Path: Path to test executable
81*1b16049aSZach Atkins        """
82*1b16049aSZach Atkins        raise NotImplementedError
83*1b16049aSZach Atkins
84*1b16049aSZach Atkins    @abstractmethod
85*1b16049aSZach Atkins    def get_output_path(self, test: str, output_file: str) -> Path:
86*1b16049aSZach Atkins        """Compute path to expected output file
87*1b16049aSZach Atkins
88*1b16049aSZach Atkins        Args:
89*1b16049aSZach Atkins            test (str): Name of test
90*1b16049aSZach Atkins            output_file (str): File name of output file
91*1b16049aSZach Atkins
92*1b16049aSZach Atkins        Returns:
93*1b16049aSZach Atkins            Path: Path to expected output file
94*1b16049aSZach Atkins        """
95*1b16049aSZach Atkins        raise NotImplementedError
96*1b16049aSZach Atkins
97*1b16049aSZach Atkins    def post_test_hook(self, test: str, spec: TestSpec) -> None:
98*1b16049aSZach Atkins        """Function callback ran after each test case
99*1b16049aSZach Atkins
100*1b16049aSZach Atkins        Args:
101*1b16049aSZach Atkins            test (str): Name of test
102*1b16049aSZach Atkins            spec (TestSpec): Test case specification
103*1b16049aSZach Atkins        """
104*1b16049aSZach Atkins        pass
105*1b16049aSZach Atkins
106*1b16049aSZach Atkins    def check_pre_skip(self, test: str, spec: TestSpec, resource: str, nproc: int) -> Optional[str]:
107*1b16049aSZach Atkins        """Check if a test case should be skipped prior to running, returning the reason for skipping
108*1b16049aSZach Atkins
109*1b16049aSZach Atkins        Args:
110*1b16049aSZach Atkins            test (str): Name of test
111*1b16049aSZach Atkins            spec (TestSpec): Test case specification
112*1b16049aSZach Atkins            resource (str): libCEED backend
113*1b16049aSZach Atkins            nproc (int): Number of MPI processes to use when running test case
114*1b16049aSZach Atkins
115*1b16049aSZach Atkins        Returns:
116*1b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if test case should not be skipped
117*1b16049aSZach Atkins        """
118*1b16049aSZach Atkins        return None
119*1b16049aSZach Atkins
120*1b16049aSZach Atkins    def check_post_skip(self, test: str, spec: TestSpec, resource: str, stderr: str) -> Optional[str]:
121*1b16049aSZach Atkins        """Check if a test case should be allowed to fail, based on its stderr output
122*1b16049aSZach Atkins
123*1b16049aSZach Atkins        Args:
124*1b16049aSZach Atkins            test (str): Name of test
125*1b16049aSZach Atkins            spec (TestSpec): Test case specification
126*1b16049aSZach Atkins            resource (str): libCEED backend
127*1b16049aSZach Atkins            stderr (str): Standard error output from test case execution
128*1b16049aSZach Atkins
129*1b16049aSZach Atkins        Returns:
130*1b16049aSZach Atkins            Optional[str]: Skip reason, or `None` if unexpeced error
131*1b16049aSZach Atkins        """
132*1b16049aSZach Atkins        return None
133*1b16049aSZach Atkins
134*1b16049aSZach Atkins    def check_required_failure(self, test: str, spec: TestSpec, resource: str, stderr: str) -> tuple[str, bool]:
135*1b16049aSZach Atkins        """Check whether a test case is expected to fail and if it failed expectedly
136*1b16049aSZach Atkins
137*1b16049aSZach Atkins        Args:
138*1b16049aSZach Atkins            test (str): Name of test
139*1b16049aSZach Atkins            spec (TestSpec): Test case specification
140*1b16049aSZach Atkins            resource (str): libCEED backend
141*1b16049aSZach Atkins            stderr (str): Standard error output from test case execution
142*1b16049aSZach Atkins
143*1b16049aSZach Atkins        Returns:
144*1b16049aSZach Atkins            tuple[str, bool]: Tuple of the expected failure string and whether it was present in `stderr`
145*1b16049aSZach Atkins        """
146*1b16049aSZach Atkins        return '', True
147*1b16049aSZach Atkins
148*1b16049aSZach Atkins    def check_allowed_stdout(self, test: str) -> bool:
149*1b16049aSZach Atkins        """Check whether a test is allowed to print console output
150*1b16049aSZach Atkins
151*1b16049aSZach Atkins        Args:
152*1b16049aSZach Atkins            test (str): Name of test
153*1b16049aSZach Atkins
154*1b16049aSZach Atkins        Returns:
155*1b16049aSZach Atkins            bool: True if the test is allowed to print console output
156*1b16049aSZach Atkins        """
157*1b16049aSZach Atkins        return False
158*1b16049aSZach Atkins
159*1b16049aSZach Atkins
160*1b16049aSZach Atkinsdef has_cgnsdiff() -> bool:
161*1b16049aSZach Atkins    """Check whether `cgnsdiff` is an executable program in the current environment
162*1b16049aSZach Atkins
163*1b16049aSZach Atkins    Returns:
164*1b16049aSZach Atkins        bool: True if `cgnsdiff` is found
165*1b16049aSZach Atkins    """
166*1b16049aSZach Atkins    my_env: dict = os.environ.copy()
167*1b16049aSZach Atkins    proc = subprocess.run('cgnsdiff',
168*1b16049aSZach Atkins                          shell=True,
169*1b16049aSZach Atkins                          stdout=subprocess.PIPE,
170*1b16049aSZach Atkins                          stderr=subprocess.PIPE,
171*1b16049aSZach Atkins                          env=my_env)
172*1b16049aSZach Atkins    return 'not found' not in proc.stderr.decode('utf-8')
173*1b16049aSZach Atkins
174*1b16049aSZach Atkins
175*1b16049aSZach Atkinsdef contains_any(base: str, substrings: list[str]) -> bool:
176*1b16049aSZach Atkins    """Helper function, checks if any of the substrings are included in the base string
177*1b16049aSZach Atkins
178*1b16049aSZach Atkins    Args:
179*1b16049aSZach Atkins        base (str): Base string to search in
180*1b16049aSZach Atkins        substrings (list[str]): List of potential substrings
181*1b16049aSZach Atkins
182*1b16049aSZach Atkins    Returns:
183*1b16049aSZach Atkins        bool: True if any substrings are included in base string
184*1b16049aSZach Atkins    """
185*1b16049aSZach Atkins    return any((sub in base for sub in substrings))
186*1b16049aSZach Atkins
187*1b16049aSZach Atkins
188*1b16049aSZach Atkinsdef startswith_any(base: str, prefixes: list[str]) -> bool:
189*1b16049aSZach Atkins    """Helper function, checks if the base string is prefixed by any of `prefixes`
190*1b16049aSZach Atkins
191*1b16049aSZach Atkins    Args:
192*1b16049aSZach Atkins        base (str): Base string to search
193*1b16049aSZach Atkins        prefixes (list[str]): List of potential prefixes
194*1b16049aSZach Atkins
195*1b16049aSZach Atkins    Returns:
196*1b16049aSZach Atkins        bool: True if base string is prefixed by any of the prefixes
197*1b16049aSZach Atkins    """
198*1b16049aSZach Atkins    return any((base.startswith(prefix) for prefix in prefixes))
199*1b16049aSZach Atkins
200*1b16049aSZach Atkins
201*1b16049aSZach Atkinsdef parse_test_line(line: str) -> TestSpec:
202*1b16049aSZach Atkins    """Parse a single line of TESTARGS and CLI arguments into a `TestSpec` object
203*1b16049aSZach Atkins
204*1b16049aSZach Atkins    Args:
205*1b16049aSZach Atkins        line (str): String containing TESTARGS specification and CLI arguments
206*1b16049aSZach Atkins
207*1b16049aSZach Atkins    Returns:
208*1b16049aSZach Atkins        TestSpec: Parsed specification of test case
209*1b16049aSZach Atkins    """
210*1b16049aSZach Atkins    args: list[str] = re.findall("(?:\".*?\"|\\S)+", line.strip())
211*1b16049aSZach Atkins    if args[0] == 'TESTARGS':
212*1b16049aSZach Atkins        return TestSpec(name='', args=args[1:])
213*1b16049aSZach Atkins    raw_test_args: str = args[0][args[0].index('TESTARGS(') + 9:args[0].rindex(')')]
214*1b16049aSZach Atkins    # transform 'name="myname",only="serial,int32"' into {'name': 'myname', 'only': 'serial,int32'}
215*1b16049aSZach Atkins    test_args: dict = dict([''.join(t).split('=') for t in re.findall(r"""([^,=]+)(=)"([^"]*)\"""", raw_test_args)])
216*1b16049aSZach Atkins    constraints: list[str] = test_args['only'].split(',') if 'only' in test_args else []
217*1b16049aSZach Atkins    if len(args) > 1:
218*1b16049aSZach Atkins        return TestSpec(name=test_args['name'], only=constraints, args=args[1:])
219*1b16049aSZach Atkins    else:
220*1b16049aSZach Atkins        return TestSpec(name=test_args['name'], only=constraints)
221*1b16049aSZach Atkins
222*1b16049aSZach Atkins
223*1b16049aSZach Atkinsdef get_test_args(source_file: Path) -> list[TestSpec]:
224*1b16049aSZach Atkins    """Parse all test cases from a given source file
225*1b16049aSZach Atkins
226*1b16049aSZach Atkins    Args:
227*1b16049aSZach Atkins        source_file (Path): Path to source file
228*1b16049aSZach Atkins
229*1b16049aSZach Atkins    Raises:
230*1b16049aSZach Atkins        RuntimeError: Errors if source file extension is unsupported
231*1b16049aSZach Atkins
232*1b16049aSZach Atkins    Returns:
233*1b16049aSZach Atkins        list[TestSpec]: List of parsed `TestSpec` objects, or a list containing a single, default `TestSpec` if none were found
234*1b16049aSZach Atkins    """
235*1b16049aSZach Atkins    comment_str: str = ''
236*1b16049aSZach Atkins    if source_file.suffix in ['.c', '.cpp']:
237*1b16049aSZach Atkins        comment_str = '//'
238*1b16049aSZach Atkins    elif source_file.suffix in ['.py']:
239*1b16049aSZach Atkins        comment_str = '#'
240*1b16049aSZach Atkins    elif source_file.suffix in ['.usr']:
241*1b16049aSZach Atkins        comment_str = 'C_'
242*1b16049aSZach Atkins    elif source_file.suffix in ['.f90']:
243*1b16049aSZach Atkins        comment_str = '! '
244*1b16049aSZach Atkins    else:
245*1b16049aSZach Atkins        raise RuntimeError(f'Unrecognized extension for file: {source_file}')
246*1b16049aSZach Atkins
247*1b16049aSZach Atkins    return [parse_test_line(line.strip(comment_str))
248*1b16049aSZach Atkins            for line in source_file.read_text().splitlines()
249*1b16049aSZach Atkins            if line.startswith(f'{comment_str}TESTARGS')] or [TestSpec('', args=['{ceed_resource}'])]
250*1b16049aSZach Atkins
251*1b16049aSZach Atkins
252*1b16049aSZach Atkinsdef diff_csv(test_csv: Path, true_csv: Path, zero_tol: float = 3e-10, rel_tol: float = 1e-2) -> str:
253*1b16049aSZach Atkins    """Compare CSV results against an expected CSV file with tolerances
254*1b16049aSZach Atkins
255*1b16049aSZach Atkins    Args:
256*1b16049aSZach Atkins        test_csv (Path): Path to output CSV results
257*1b16049aSZach Atkins        true_csv (Path): Path to expected CSV results
258*1b16049aSZach Atkins        zero_tol (float, optional): Tolerance below which values are considered to be zero. Defaults to 3e-10.
259*1b16049aSZach Atkins        rel_tol (float, optional): Relative tolerance for comparing non-zero values. Defaults to 1e-2.
260*1b16049aSZach Atkins
261*1b16049aSZach Atkins    Returns:
262*1b16049aSZach Atkins        str: Diff output between result and expected CSVs
263*1b16049aSZach Atkins    """
264*1b16049aSZach Atkins    test_lines: list[str] = test_csv.read_text().splitlines()
265*1b16049aSZach Atkins    true_lines: list[str] = true_csv.read_text().splitlines()
266*1b16049aSZach Atkins
267*1b16049aSZach Atkins    if test_lines[0] != true_lines[0]:
268*1b16049aSZach Atkins        return ''.join(difflib.unified_diff([f'{test_lines[0]}\n'], [f'{true_lines[0]}\n'],
269*1b16049aSZach Atkins                       tofile='found CSV columns', fromfile='expected CSV columns'))
270*1b16049aSZach Atkins
271*1b16049aSZach Atkins    diff_lines: list[str] = list()
272*1b16049aSZach Atkins    column_names: list[str] = true_lines[0].strip().split(',')
273*1b16049aSZach Atkins    for test_line, true_line in zip(test_lines[1:], true_lines[1:]):
274*1b16049aSZach Atkins        test_vals: list[float] = [float(val.strip()) for val in test_line.strip().split(',')]
275*1b16049aSZach Atkins        true_vals: list[float] = [float(val.strip()) for val in true_line.strip().split(',')]
276*1b16049aSZach Atkins        for test_val, true_val, column_name in zip(test_vals, true_vals, column_names):
277*1b16049aSZach Atkins            true_zero: bool = abs(true_val) < zero_tol
278*1b16049aSZach Atkins            test_zero: bool = abs(test_val) < zero_tol
279*1b16049aSZach Atkins            fail: bool = False
280*1b16049aSZach Atkins            if true_zero:
281*1b16049aSZach Atkins                fail = not test_zero
282*1b16049aSZach Atkins            else:
283*1b16049aSZach Atkins                fail = not isclose(test_val, true_val, rel_tol=rel_tol)
284*1b16049aSZach Atkins            if fail:
285*1b16049aSZach Atkins                diff_lines.append(f'step: {true_line[0]}, column: {column_name}, expected: {true_val}, got: {test_val}')
286*1b16049aSZach Atkins    return '\n'.join(diff_lines)
287*1b16049aSZach Atkins
288*1b16049aSZach Atkins
289*1b16049aSZach Atkinsdef diff_cgns(test_cgns: Path, true_cgns: Path, tolerance: float = 1e-12) -> str:
290*1b16049aSZach Atkins    """Compare CGNS results against an expected CGSN file with tolerance
291*1b16049aSZach Atkins
292*1b16049aSZach Atkins    Args:
293*1b16049aSZach Atkins        test_cgns (Path): Path to output CGNS file
294*1b16049aSZach Atkins        true_cgns (Path): Path to expected CGNS file
295*1b16049aSZach Atkins        tolerance (float, optional): Tolerance for comparing floating-point values
296*1b16049aSZach Atkins
297*1b16049aSZach Atkins    Returns:
298*1b16049aSZach Atkins        str: Diff output between result and expected CGNS files
299*1b16049aSZach Atkins    """
300*1b16049aSZach Atkins    my_env: dict = os.environ.copy()
301*1b16049aSZach Atkins
302*1b16049aSZach Atkins    run_args: list[str] = ['cgnsdiff', '-d', '-t', f'{tolerance}', str(test_cgns), str(true_cgns)]
303*1b16049aSZach Atkins    proc = subprocess.run(' '.join(run_args),
304*1b16049aSZach Atkins                          shell=True,
305*1b16049aSZach Atkins                          stdout=subprocess.PIPE,
306*1b16049aSZach Atkins                          stderr=subprocess.PIPE,
307*1b16049aSZach Atkins                          env=my_env)
308*1b16049aSZach Atkins
309*1b16049aSZach Atkins    return proc.stderr.decode('utf-8') + proc.stdout.decode('utf-8')
310*1b16049aSZach Atkins
311*1b16049aSZach Atkins
312*1b16049aSZach Atkinsdef run_tests(test: str, ceed_backends: list[str], mode: RunMode, nproc: int, suite_spec: SuiteSpec) -> TestSuite:
313*1b16049aSZach Atkins    """Run all test cases for `test` with each of the provided `ceed_backends`
314*1b16049aSZach Atkins
315*1b16049aSZach Atkins    Args:
316*1b16049aSZach Atkins        test (str): Name of test
317*1b16049aSZach Atkins        ceed_backends (list[str]): List of libCEED backends
318*1b16049aSZach Atkins        mode (RunMode): Output mode, either `RunMode.TAP` or `RunMode.JUNIT`
319*1b16049aSZach Atkins        nproc (int): Number of MPI processes to use when running each test case
320*1b16049aSZach Atkins        suite_spec (SuiteSpec): Object defining required methods for running tests
321*1b16049aSZach Atkins
322*1b16049aSZach Atkins    Returns:
323*1b16049aSZach Atkins        TestSuite: JUnit `TestSuite` containing results of all test cases
324*1b16049aSZach Atkins    """
325*1b16049aSZach Atkins    source_path: Path = suite_spec.get_source_path(test)
326*1b16049aSZach Atkins    test_specs: list[TestSpec] = get_test_args(source_path)
327*1b16049aSZach Atkins
328*1b16049aSZach Atkins    if mode is RunMode.TAP:
329*1b16049aSZach Atkins        print('1..' + str(len(test_specs) * len(ceed_backends)))
330*1b16049aSZach Atkins
331*1b16049aSZach Atkins    test_cases: list[TestCase] = []
332*1b16049aSZach Atkins    my_env: dict = os.environ.copy()
333*1b16049aSZach Atkins    my_env['CEED_ERROR_HANDLER'] = 'exit'
334*1b16049aSZach Atkins
335*1b16049aSZach Atkins    index: int = 1
336*1b16049aSZach Atkins    for spec in test_specs:
337*1b16049aSZach Atkins        for ceed_resource in ceed_backends:
338*1b16049aSZach Atkins            run_args: list = [suite_spec.get_run_path(test), *spec.args]
339*1b16049aSZach Atkins
340*1b16049aSZach Atkins            if '{ceed_resource}' in run_args:
341*1b16049aSZach Atkins                run_args[run_args.index('{ceed_resource}')] = ceed_resource
342*1b16049aSZach Atkins            if '{nproc}' in run_args:
343*1b16049aSZach Atkins                run_args[run_args.index('{nproc}')] = f'{nproc}'
344*1b16049aSZach Atkins            elif nproc > 1 and source_path.suffix != '.py':
345*1b16049aSZach Atkins                run_args = ['mpiexec', '-n', f'{nproc}', *run_args]
346*1b16049aSZach Atkins
347*1b16049aSZach Atkins            # run test
348*1b16049aSZach Atkins            skip_reason: str = suite_spec.check_pre_skip(test, spec, ceed_resource, nproc)
349*1b16049aSZach Atkins            if skip_reason:
350*1b16049aSZach Atkins                test_case: TestCase = TestCase(f'{test}, "{spec.name}", n{nproc}, {ceed_resource}',
351*1b16049aSZach Atkins                                               elapsed_sec=0,
352*1b16049aSZach Atkins                                               timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime()),
353*1b16049aSZach Atkins                                               stdout='',
354*1b16049aSZach Atkins                                               stderr='')
355*1b16049aSZach Atkins                test_case.add_skipped_info(skip_reason)
356*1b16049aSZach Atkins            else:
357*1b16049aSZach Atkins                start: float = time.time()
358*1b16049aSZach Atkins                proc = subprocess.run(' '.join(str(arg) for arg in run_args),
359*1b16049aSZach Atkins                                      shell=True,
360*1b16049aSZach Atkins                                      stdout=subprocess.PIPE,
361*1b16049aSZach Atkins                                      stderr=subprocess.PIPE,
362*1b16049aSZach Atkins                                      env=my_env)
363*1b16049aSZach Atkins
364*1b16049aSZach Atkins                test_case = TestCase(f'{test}, "{spec.name}", n{nproc}, {ceed_resource}',
365*1b16049aSZach Atkins                                     classname=source_path.parent,
366*1b16049aSZach Atkins                                     elapsed_sec=time.time() - start,
367*1b16049aSZach Atkins                                     timestamp=time.strftime('%Y-%m-%d %H:%M:%S %Z', time.localtime(start)),
368*1b16049aSZach Atkins                                     stdout=proc.stdout.decode('utf-8'),
369*1b16049aSZach Atkins                                     stderr=proc.stderr.decode('utf-8'),
370*1b16049aSZach Atkins                                     allow_multiple_subelements=True)
371*1b16049aSZach Atkins                ref_csvs: list[Path] = []
372*1b16049aSZach Atkins                if output_files := [arg for arg in spec.args if 'ascii:' in arg]:
373*1b16049aSZach Atkins                    ref_csvs = [suite_spec.get_output_path(test, file.split('ascii:')[-1]) for file in output_files]
374*1b16049aSZach Atkins                ref_cgns: list[Path] = []
375*1b16049aSZach Atkins                if output_files := [arg for arg in spec.args if 'cgns:' in arg]:
376*1b16049aSZach Atkins                    ref_cgns = [suite_spec.get_output_path(test, file.split('cgns:')[-1]) for file in output_files]
377*1b16049aSZach Atkins                ref_stdout: Path = suite_spec.get_output_path(test, test + '.out')
378*1b16049aSZach Atkins                suite_spec.post_test_hook(test, spec)
379*1b16049aSZach Atkins
380*1b16049aSZach Atkins            # check allowed failures
381*1b16049aSZach Atkins            if not test_case.is_skipped() and test_case.stderr:
382*1b16049aSZach Atkins                skip_reason: str = suite_spec.check_post_skip(test, spec, ceed_resource, test_case.stderr)
383*1b16049aSZach Atkins                if skip_reason:
384*1b16049aSZach Atkins                    test_case.add_skipped_info(skip_reason)
385*1b16049aSZach Atkins
386*1b16049aSZach Atkins            # check required failures
387*1b16049aSZach Atkins            if not test_case.is_skipped():
388*1b16049aSZach Atkins                required_message, did_fail = suite_spec.check_required_failure(test, spec, ceed_resource, test_case.stderr)
389*1b16049aSZach Atkins                if required_message and did_fail:
390*1b16049aSZach Atkins                    test_case.status = f'fails with required: {required_message}'
391*1b16049aSZach Atkins                elif required_message:
392*1b16049aSZach Atkins                    test_case.add_failure_info(f'required failure missing: {required_message}')
393*1b16049aSZach Atkins
394*1b16049aSZach Atkins            # classify other results
395*1b16049aSZach Atkins            if not test_case.is_skipped() and not test_case.status:
396*1b16049aSZach Atkins                if test_case.stderr:
397*1b16049aSZach Atkins                    test_case.add_failure_info('stderr', test_case.stderr)
398*1b16049aSZach Atkins                if proc.returncode != 0:
399*1b16049aSZach Atkins                    test_case.add_error_info(f'returncode = {proc.returncode}')
400*1b16049aSZach Atkins                if ref_stdout.is_file():
401*1b16049aSZach Atkins                    diff = list(difflib.unified_diff(ref_stdout.read_text().splitlines(keepends=True),
402*1b16049aSZach Atkins                                                     test_case.stdout.splitlines(keepends=True),
403*1b16049aSZach Atkins                                                     fromfile=str(ref_stdout),
404*1b16049aSZach Atkins                                                     tofile='New'))
405*1b16049aSZach Atkins                    if diff:
406*1b16049aSZach Atkins                        test_case.add_failure_info('stdout', output=''.join(diff))
407*1b16049aSZach Atkins                elif test_case.stdout and not suite_spec.check_allowed_stdout(test):
408*1b16049aSZach Atkins                    test_case.add_failure_info('stdout', output=test_case.stdout)
409*1b16049aSZach Atkins                # expected CSV output
410*1b16049aSZach Atkins                for ref_csv in ref_csvs:
411*1b16049aSZach Atkins                    if not ref_csv.is_file():
412*1b16049aSZach Atkins                        test_case.add_failure_info('csv', output=f'{ref_csv} not found')
413*1b16049aSZach Atkins                    else:
414*1b16049aSZach Atkins                        diff: str = diff_csv(Path.cwd() / ref_csv.name, ref_csv)
415*1b16049aSZach Atkins                        if diff:
416*1b16049aSZach Atkins                            test_case.add_failure_info('csv', output=diff)
417*1b16049aSZach Atkins                        else:
418*1b16049aSZach Atkins                            (Path.cwd() / ref_csv.name).unlink()
419*1b16049aSZach Atkins                # expected CGNS output
420*1b16049aSZach Atkins                for ref_cgn in ref_cgns:
421*1b16049aSZach Atkins                    if not ref_cgn.is_file():
422*1b16049aSZach Atkins                        test_case.add_failure_info('cgns', output=f'{ref_cgn} not found')
423*1b16049aSZach Atkins                    else:
424*1b16049aSZach Atkins                        diff = diff_cgns(Path.cwd() / ref_cgn.name, ref_cgn)
425*1b16049aSZach Atkins                        if diff:
426*1b16049aSZach Atkins                            test_case.add_failure_info('cgns', output=diff)
427*1b16049aSZach Atkins                        else:
428*1b16049aSZach Atkins                            (Path.cwd() / ref_cgn.name).unlink()
429*1b16049aSZach Atkins
430*1b16049aSZach Atkins            # store result
431*1b16049aSZach Atkins            test_case.args = ' '.join(str(arg) for arg in run_args)
432*1b16049aSZach Atkins            test_cases.append(test_case)
433*1b16049aSZach Atkins
434*1b16049aSZach Atkins            if mode is RunMode.TAP:
435*1b16049aSZach Atkins                # print incremental output if TAP mode
436*1b16049aSZach Atkins                print(f'# Test: {spec.name}')
437*1b16049aSZach Atkins                if spec.only:
438*1b16049aSZach Atkins                    print('# Only: {}'.format(','.join(spec.only)))
439*1b16049aSZach Atkins                print(f'# $ {test_case.args}')
440*1b16049aSZach Atkins                if test_case.is_skipped():
441*1b16049aSZach Atkins                    print('ok {} - SKIP: {}'.format(index, (test_case.skipped[0]['message'] or 'NO MESSAGE').strip()))
442*1b16049aSZach Atkins                elif test_case.is_failure() or test_case.is_error():
443*1b16049aSZach Atkins                    print(f'not ok {index}')
444*1b16049aSZach Atkins                    if test_case.is_error():
445*1b16049aSZach Atkins                        print(f'  ERROR: {test_case.errors[0]["message"]}')
446*1b16049aSZach Atkins                    if test_case.is_failure():
447*1b16049aSZach Atkins                        for i, failure in enumerate(test_case.failures):
448*1b16049aSZach Atkins                            print(f'  FAILURE {i}: {failure["message"]}')
449*1b16049aSZach Atkins                            print(f'    Output: \n{failure["output"]}')
450*1b16049aSZach Atkins                else:
451*1b16049aSZach Atkins                    print(f'ok {index} - PASS')
452*1b16049aSZach Atkins                sys.stdout.flush()
453*1b16049aSZach Atkins            else:
454*1b16049aSZach Atkins                # print error or failure information if JUNIT mode
455*1b16049aSZach Atkins                if test_case.is_error() or test_case.is_failure():
456*1b16049aSZach Atkins                    print(f'Test: {test} {spec.name}')
457*1b16049aSZach Atkins                    print(f'  $ {test_case.args}')
458*1b16049aSZach Atkins                    if test_case.is_error():
459*1b16049aSZach Atkins                        print('ERROR: {}'.format((test_case.errors[0]['message'] or 'NO MESSAGE').strip()))
460*1b16049aSZach Atkins                        print('Output: \n{}'.format((test_case.errors[0]['output'] or 'NO MESSAGE').strip()))
461*1b16049aSZach Atkins                    if test_case.is_failure():
462*1b16049aSZach Atkins                        for failure in test_case.failures:
463*1b16049aSZach Atkins                            print('FAIL: {}'.format((failure['message'] or 'NO MESSAGE').strip()))
464*1b16049aSZach Atkins                            print('Output: \n{}'.format((failure['output'] or 'NO MESSAGE').strip()))
465*1b16049aSZach Atkins                sys.stdout.flush()
466*1b16049aSZach Atkins            index += 1
467*1b16049aSZach Atkins
468*1b16049aSZach Atkins    return TestSuite(test, test_cases)
469*1b16049aSZach Atkins
470*1b16049aSZach Atkins
471*1b16049aSZach Atkinsdef write_junit_xml(test_suite: TestSuite, output_file: Optional[Path], batch: str = '') -> None:
472*1b16049aSZach Atkins    """Write a JUnit XML file containing the results of a `TestSuite`
473*1b16049aSZach Atkins
474*1b16049aSZach Atkins    Args:
475*1b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to write
476*1b16049aSZach Atkins        output_file (Optional[Path]): Path to output file, or `None` to generate automatically as `build/{test_suite.name}{batch}.junit`
477*1b16049aSZach Atkins        batch (str): Name of JUnit batch, defaults to empty string
478*1b16049aSZach Atkins    """
479*1b16049aSZach Atkins    output_file: Path = output_file or Path('build') / (f'{test_suite.name}{batch}.junit')
480*1b16049aSZach Atkins    output_file.write_text(to_xml_report_string([test_suite]))
481*1b16049aSZach Atkins
482*1b16049aSZach Atkins
483*1b16049aSZach Atkinsdef has_failures(test_suite: TestSuite) -> bool:
484*1b16049aSZach Atkins    """Check whether any test cases in a `TestSuite` failed
485*1b16049aSZach Atkins
486*1b16049aSZach Atkins    Args:
487*1b16049aSZach Atkins        test_suite (TestSuite): JUnit `TestSuite` to check
488*1b16049aSZach Atkins
489*1b16049aSZach Atkins    Returns:
490*1b16049aSZach Atkins        bool: True if any test cases failed
491*1b16049aSZach Atkins    """
492*1b16049aSZach Atkins    return any(c.is_failure() or c.is_error() for c in test_suite.test_cases)
493