xref: /dpdk/dts/framework/test_suite.py (revision e3ab9dd5cd5d5e7cb117507ba9580dae9706c1f5)
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright(c) 2010-2014 Intel Corporation
3# Copyright(c) 2023 PANTHEON.tech s.r.o.
4# Copyright(c) 2024 Arm Limited
5
6"""Features common to all test suites.
7
8The module defines the :class:`TestSuite` class which doesn't contain any test cases, and as such
9must be extended by subclasses which add test cases. The :class:`TestSuite` contains the basics
10needed by subclasses:
11
12    * Testbed (SUT, TG) configuration,
13    * Packet sending and verification,
14    * Test case verification.
15"""
16
17import inspect
18from collections import Counter
19from collections.abc import Callable, Sequence
20from dataclasses import dataclass
21from enum import Enum, auto
22from functools import cached_property
23from importlib import import_module
24from ipaddress import IPv4Interface, IPv6Interface, ip_interface
25from pkgutil import iter_modules
26from types import ModuleType
27from typing import ClassVar, Protocol, TypeVar, Union, cast
28
29from scapy.layers.inet import IP  # type: ignore[import-untyped]
30from scapy.layers.l2 import Ether  # type: ignore[import-untyped]
31from scapy.packet import Packet, Padding, raw  # type: ignore[import-untyped]
32from typing_extensions import Self
33
34from framework.testbed_model.capability import TestProtocol
35from framework.testbed_model.port import Port
36from framework.testbed_model.sut_node import SutNode
37from framework.testbed_model.tg_node import TGNode
38from framework.testbed_model.topology import Topology
39from framework.testbed_model.traffic_generator.capturing_traffic_generator import (
40    PacketFilteringConfig,
41)
42
43from .exception import ConfigurationError, InternalError, TestCaseVerifyError
44from .logger import DTSLogger, get_dts_logger
45from .utils import get_packet_summaries, to_pascal_case
46
47
48class TestSuite(TestProtocol):
49    """The base class with building blocks needed by most test cases.
50
51        * Test suite setup/cleanup methods to override,
52        * Test case setup/cleanup methods to override,
53        * Test case verification,
54        * Testbed configuration,
55        * Traffic sending and verification.
56
57    Test cases are implemented by subclasses. Test cases are all methods starting with ``test_``,
58    further divided into performance test cases (starting with ``test_perf_``)
59    and functional test cases (all other test cases).
60
61    By default, all test cases will be executed. A list of testcase names may be specified
62    in the YAML test run configuration file and in the :option:`--test-suite` command line argument
63    or in the :envvar:`DTS_TESTCASES` environment variable to filter which test cases to run.
64    The union of both lists will be used. Any unknown test cases from the latter lists
65    will be silently ignored.
66
67    The methods named ``[set_up|tear_down]_[suite|test_case]`` should be overridden in subclasses
68    if the appropriate test suite/test case fixtures are needed.
69
70    The test suite is aware of the testbed (the SUT and TG) it's running on. From this, it can
71    properly choose the IP addresses and other configuration that must be tailored to the testbed.
72
73    Attributes:
74        sut_node: The SUT node where the test suite is running.
75        tg_node: The TG node where the test suite is running.
76    """
77
78    sut_node: SutNode
79    tg_node: TGNode
80    #: Whether the test suite is blocking. A failure of a blocking test suite
81    #: will block the execution of all subsequent test suites in the current test run.
82    is_blocking: ClassVar[bool] = False
83    _logger: DTSLogger
84    _sut_port_ingress: Port
85    _sut_port_egress: Port
86    _sut_ip_address_ingress: Union[IPv4Interface, IPv6Interface]
87    _sut_ip_address_egress: Union[IPv4Interface, IPv6Interface]
88    _tg_port_ingress: Port
89    _tg_port_egress: Port
90    _tg_ip_address_ingress: Union[IPv4Interface, IPv6Interface]
91    _tg_ip_address_egress: Union[IPv4Interface, IPv6Interface]
92
93    def __init__(
94        self,
95        sut_node: SutNode,
96        tg_node: TGNode,
97        topology: Topology,
98    ):
99        """Initialize the test suite testbed information and basic configuration.
100
101        Find links between ports and set up default IP addresses to be used when
102        configuring them.
103
104        Args:
105            sut_node: The SUT node where the test suite will run.
106            tg_node: The TG node where the test suite will run.
107            topology: The topology where the test suite will run.
108        """
109        self.sut_node = sut_node
110        self.tg_node = tg_node
111        self._logger = get_dts_logger(self.__class__.__name__)
112        self._tg_port_egress = topology.tg_port_egress
113        self._sut_port_ingress = topology.sut_port_ingress
114        self._sut_port_egress = topology.sut_port_egress
115        self._tg_port_ingress = topology.tg_port_ingress
116        self._sut_ip_address_ingress = ip_interface("192.168.100.2/24")
117        self._sut_ip_address_egress = ip_interface("192.168.101.2/24")
118        self._tg_ip_address_egress = ip_interface("192.168.100.3/24")
119        self._tg_ip_address_ingress = ip_interface("192.168.101.3/24")
120
121    @classmethod
122    def get_test_cases(cls) -> list[type["TestCase"]]:
123        """A list of all the available test cases."""
124
125        def is_test_case(function: Callable) -> bool:
126            if inspect.isfunction(function):
127                # TestCase is not used at runtime, so we can't use isinstance() with `function`.
128                # But function.test_type exists.
129                if hasattr(function, "test_type"):
130                    return isinstance(function.test_type, TestCaseType)
131            return False
132
133        return [test_case for _, test_case in inspect.getmembers(cls, is_test_case)]
134
135    @classmethod
136    def filter_test_cases(
137        cls, test_case_sublist: Sequence[str] | None = None
138    ) -> tuple[set[type["TestCase"]], set[type["TestCase"]]]:
139        """Filter `test_case_sublist` from this class.
140
141        Test cases are regular (or bound) methods decorated with :func:`func_test`
142        or :func:`perf_test`.
143
144        Args:
145            test_case_sublist: Test case names to filter from this class.
146                If empty or :data:`None`, return all test cases.
147
148        Returns:
149            The filtered test case functions. This method returns functions as opposed to methods,
150            as methods are bound to instances and this method only has access to the class.
151
152        Raises:
153            ConfigurationError: If a test case from `test_case_sublist` is not found.
154        """
155        if test_case_sublist is None:
156            test_case_sublist = []
157
158        # the copy is needed so that the condition "elif test_case_sublist" doesn't
159        # change mid-cycle
160        test_case_sublist_copy = list(test_case_sublist)
161        func_test_cases = set()
162        perf_test_cases = set()
163
164        for test_case in cls.get_test_cases():
165            if test_case.name in test_case_sublist_copy:
166                # if test_case_sublist_copy is non-empty, remove the found test case
167                # so that we can look at the remainder at the end
168                test_case_sublist_copy.remove(test_case.name)
169            elif test_case_sublist:
170                # the original list not being empty means we're filtering test cases
171                # since we didn't remove test_case.name in the previous branch,
172                # it doesn't match the filter and we don't want to remove it
173                continue
174
175            match test_case.test_type:
176                case TestCaseType.PERFORMANCE:
177                    perf_test_cases.add(test_case)
178                case TestCaseType.FUNCTIONAL:
179                    func_test_cases.add(test_case)
180
181        if test_case_sublist_copy:
182            raise ConfigurationError(
183                f"Test cases {test_case_sublist_copy} not found among functions of {cls.__name__}."
184            )
185
186        return func_test_cases, perf_test_cases
187
188    def set_up_suite(self) -> None:
189        """Set up test fixtures common to all test cases.
190
191        This is done before any test case has been run.
192        """
193
194    def tear_down_suite(self) -> None:
195        """Tear down the previously created test fixtures common to all test cases.
196
197        This is done after all test have been run.
198        """
199
200    def set_up_test_case(self) -> None:
201        """Set up test fixtures before each test case.
202
203        This is done before *each* test case.
204        """
205
206    def tear_down_test_case(self) -> None:
207        """Tear down the previously created test fixtures after each test case.
208
209        This is done after *each* test case.
210        """
211
212    def send_packet_and_capture(
213        self,
214        packet: Packet,
215        filter_config: PacketFilteringConfig = PacketFilteringConfig(),
216        duration: float = 1,
217    ) -> list[Packet]:
218        """Send and receive `packet` using the associated TG.
219
220        Send `packet` through the appropriate interface and receive on the appropriate interface.
221        Modify the packet with l3/l2 addresses corresponding to the testbed and desired traffic.
222
223        Args:
224            packet: The packet to send.
225            filter_config: The filter to use when capturing packets.
226            duration: Capture traffic for this amount of time after sending `packet`.
227
228        Returns:
229            A list of received packets.
230        """
231        return self.send_packets_and_capture(
232            [packet],
233            filter_config,
234            duration,
235        )
236
237    def send_packets_and_capture(
238        self,
239        packets: list[Packet],
240        filter_config: PacketFilteringConfig = PacketFilteringConfig(),
241        duration: float = 1,
242    ) -> list[Packet]:
243        """Send and receive `packets` using the associated TG.
244
245        Send `packets` through the appropriate interface and receive on the appropriate interface.
246        Modify the packets with l3/l2 addresses corresponding to the testbed and desired traffic.
247
248        Args:
249            packets: The packets to send.
250            filter_config: The filter to use when capturing packets.
251            duration: Capture traffic for this amount of time after sending `packet`.
252
253        Returns:
254            A list of received packets.
255        """
256        packets = self._adjust_addresses(packets)
257        return self.tg_node.send_packets_and_capture(
258            packets,
259            self._tg_port_egress,
260            self._tg_port_ingress,
261            filter_config,
262            duration,
263        )
264
265    def send_packets(
266        self,
267        packets: list[Packet],
268    ) -> None:
269        """Send packets using the traffic generator and do not capture received traffic.
270
271        Args:
272            packets: Packets to send.
273        """
274        packets = self._adjust_addresses(packets)
275        self.tg_node.send_packets(packets, self._tg_port_egress)
276
277    def get_expected_packets(self, packets: list[Packet]) -> list[Packet]:
278        """Inject the proper L2/L3 addresses into `packets`.
279
280        Inject the L2/L3 addresses expected at the receiving end of the traffic generator.
281
282        Args:
283            packets: The packets to modify.
284
285        Returns:
286            `packets` with injected L2/L3 addresses.
287        """
288        return self._adjust_addresses(packets, expected=True)
289
290    def get_expected_packet(self, packet: Packet) -> Packet:
291        """Inject the proper L2/L3 addresses into `packet`.
292
293        Inject the L2/L3 addresses expected at the receiving end of the traffic generator.
294
295        Args:
296            packet: The packet to modify.
297
298        Returns:
299            `packet` with injected L2/L3 addresses.
300        """
301        return self.get_expected_packets([packet])[0]
302
303    def _adjust_addresses(self, packets: list[Packet], expected: bool = False) -> list[Packet]:
304        """L2 and L3 address additions in both directions.
305
306        Copies of `packets` will be made, modified and returned in this method.
307
308        Only missing addresses are added to packets, existing addresses will not be overridden. If
309        any packet in `packets` has multiple IP layers (using GRE, for example) only the inner-most
310        IP layer will have its addresses adjusted.
311
312        Assumptions:
313            Two links between SUT and TG, one link is TG -> SUT, the other SUT -> TG.
314
315        Args:
316            packets: The packets to modify.
317            expected: If :data:`True`, the direction is SUT -> TG,
318                otherwise the direction is TG -> SUT.
319
320        Returns:
321            A list containing copies of all packets in `packets` after modification.
322        """
323        ret_packets = []
324        for original_packet in packets:
325            packet = original_packet.copy()
326
327            # update l2 addresses
328            # If `expected` is :data:`True`, the packet enters the TG from SUT, otherwise the
329            # packet leaves the TG towards the SUT.
330
331            # The fields parameter of a packet does not include fields of the payload, so this can
332            # only be the Ether src/dst.
333            if "src" not in packet.fields:
334                packet.src = (
335                    self._sut_port_egress.mac_address
336                    if expected
337                    else self._tg_port_egress.mac_address
338                )
339            if "dst" not in packet.fields:
340                packet.dst = (
341                    self._tg_port_ingress.mac_address
342                    if expected
343                    else self._sut_port_ingress.mac_address
344                )
345
346            # update l3 addresses
347            # The packet is routed from TG egress to TG ingress regardless of whether it is
348            # expected or not.
349            num_ip_layers = packet.layers().count(IP)
350            if num_ip_layers > 0:
351                # Update the last IP layer if there are multiple (the framework should be modifying
352                # the packet address instead of the tunnel address if there is one).
353                l3_to_use = packet.getlayer(IP, num_ip_layers)
354                if "src" not in l3_to_use.fields:
355                    l3_to_use.src = self._tg_ip_address_egress.ip.exploded
356
357                if "dst" not in l3_to_use.fields:
358                    l3_to_use.dst = self._tg_ip_address_ingress.ip.exploded
359            ret_packets.append(Ether(packet.build()))
360
361        return ret_packets
362
363    def verify(self, condition: bool, failure_description: str) -> None:
364        """Verify `condition` and handle failures.
365
366        When `condition` is :data:`False`, raise an exception and log the last 10 commands
367        executed on both the SUT and TG.
368
369        Args:
370            condition: The condition to check.
371            failure_description: A short description of the failure
372                that will be stored in the raised exception.
373
374        Raises:
375            TestCaseVerifyError: `condition` is :data:`False`.
376        """
377        if not condition:
378            self._fail_test_case_verify(failure_description)
379
380    def _fail_test_case_verify(self, failure_description: str) -> None:
381        self._logger.debug("A test case failed, showing the last 10 commands executed on SUT:")
382        for command_res in self.sut_node.main_session.remote_session.history[-10:]:
383            self._logger.debug(command_res.command)
384        self._logger.debug("A test case failed, showing the last 10 commands executed on TG:")
385        for command_res in self.tg_node.main_session.remote_session.history[-10:]:
386            self._logger.debug(command_res.command)
387        raise TestCaseVerifyError(failure_description)
388
389    def verify_packets(self, expected_packet: Packet, received_packets: list[Packet]) -> None:
390        """Verify that `expected_packet` has been received.
391
392        Go through `received_packets` and check that `expected_packet` is among them.
393        If not, raise an exception and log the last 10 commands
394        executed on both the SUT and TG.
395
396        Args:
397            expected_packet: The packet we're expecting to receive.
398            received_packets: The packets where we're looking for `expected_packet`.
399
400        Raises:
401            TestCaseVerifyError: `expected_packet` is not among `received_packets`.
402        """
403        for received_packet in received_packets:
404            if self._compare_packets(expected_packet, received_packet):
405                break
406        else:
407            self._logger.debug(
408                f"The expected packet {get_packet_summaries(expected_packet)} "
409                f"not found among received {get_packet_summaries(received_packets)}"
410            )
411            self._fail_test_case_verify("An expected packet not found among received packets.")
412
413    def match_all_packets(
414        self, expected_packets: list[Packet], received_packets: list[Packet]
415    ) -> None:
416        """Matches all the expected packets against the received ones.
417
418        Matching is performed by counting down the occurrences in a dictionary which keys are the
419        raw packet bytes. No deep packet comparison is performed. All the unexpected packets (noise)
420        are automatically ignored.
421
422        Args:
423            expected_packets: The packets we are expecting to receive.
424            received_packets: All the packets that were received.
425
426        Raises:
427            TestCaseVerifyError: if and not all the `expected_packets` were found in
428                `received_packets`.
429        """
430        expected_packets_counters = Counter(map(raw, expected_packets))
431        received_packets_counters = Counter(map(raw, received_packets))
432        # The number of expected packets is subtracted by the number of received packets, ignoring
433        # any unexpected packets and capping at zero.
434        missing_packets_counters = expected_packets_counters - received_packets_counters
435        missing_packets_count = missing_packets_counters.total()
436        self._logger.debug(
437            f"match_all_packets: expected {len(expected_packets)}, "
438            f"received {len(received_packets)}, missing {missing_packets_count}"
439        )
440
441        if missing_packets_count != 0:
442            self._fail_test_case_verify(
443                f"Not all packets were received, expected {len(expected_packets)} "
444                f"but {missing_packets_count} were missing."
445            )
446
447    def _compare_packets(self, expected_packet: Packet, received_packet: Packet) -> bool:
448        self._logger.debug(
449            f"Comparing packets: \n{expected_packet.summary()}\n{received_packet.summary()}"
450        )
451
452        l3 = IP in expected_packet.layers()
453        self._logger.debug("Found l3 layer")
454
455        received_payload = received_packet
456        expected_payload = expected_packet
457        while received_payload and expected_payload:
458            self._logger.debug("Comparing payloads:")
459            self._logger.debug(f"Received: {received_payload}")
460            self._logger.debug(f"Expected: {expected_payload}")
461            if received_payload.__class__ == expected_payload.__class__:
462                self._logger.debug("The layers are the same.")
463                if received_payload.__class__ == Ether:
464                    if not self._verify_l2_frame(received_payload, l3):
465                        return False
466                elif received_payload.__class__ == IP:
467                    if not self._verify_l3_packet(received_payload, expected_payload):
468                        return False
469            else:
470                # Different layers => different packets
471                return False
472            received_payload = received_payload.payload
473            expected_payload = expected_payload.payload
474
475        if expected_payload:
476            self._logger.debug(f"The expected packet did not contain {expected_payload}.")
477            return False
478        if received_payload and received_payload.__class__ != Padding:
479            self._logger.debug("The received payload had extra layers which were not padding.")
480            return False
481        return True
482
483    def _verify_l2_frame(self, received_packet: Ether, l3: bool) -> bool:
484        self._logger.debug("Looking at the Ether layer.")
485        self._logger.debug(
486            f"Comparing received dst mac '{received_packet.dst}' "
487            f"with expected '{self._tg_port_ingress.mac_address}'."
488        )
489        if received_packet.dst != self._tg_port_ingress.mac_address:
490            return False
491
492        expected_src_mac = self._tg_port_egress.mac_address
493        if l3:
494            expected_src_mac = self._sut_port_egress.mac_address
495        self._logger.debug(
496            f"Comparing received src mac '{received_packet.src}' "
497            f"with expected '{expected_src_mac}'."
498        )
499        if received_packet.src != expected_src_mac:
500            return False
501
502        return True
503
504    def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool:
505        self._logger.debug("Looking at the IP layer.")
506        if received_packet.src != expected_packet.src or received_packet.dst != expected_packet.dst:
507            return False
508        return True
509
510
511#: The generic type for a method of an instance of TestSuite
512TestSuiteMethodType = TypeVar("TestSuiteMethodType", bound=Callable[[TestSuite], None])
513
514
515class TestCaseType(Enum):
516    """The types of test cases."""
517
518    #:
519    FUNCTIONAL = auto()
520    #:
521    PERFORMANCE = auto()
522
523
524class TestCase(TestProtocol, Protocol[TestSuiteMethodType]):
525    """Definition of the test case type for static type checking purposes.
526
527    The type is applied to test case functions through a decorator, which casts the decorated
528    test case function to :class:`TestCase` and sets common variables.
529    """
530
531    #:
532    name: ClassVar[str]
533    #:
534    test_type: ClassVar[TestCaseType]
535    #: necessary for mypy so that it can treat this class as the function it's shadowing
536    __call__: TestSuiteMethodType
537
538    @classmethod
539    def make_decorator(
540        cls, test_case_type: TestCaseType
541    ) -> Callable[[TestSuiteMethodType], type["TestCase"]]:
542        """Create a decorator for test suites.
543
544        The decorator casts the decorated function as :class:`TestCase`,
545        sets it as `test_case_type`
546        and initializes common variables defined in :class:`RequiresCapabilities`.
547
548        Args:
549            test_case_type: Either a functional or performance test case.
550
551        Returns:
552            The decorator of a functional or performance test case.
553        """
554
555        def _decorator(func: TestSuiteMethodType) -> type[TestCase]:
556            test_case = cast(type[TestCase], func)
557            test_case.name = func.__name__
558            test_case.skip = cls.skip
559            test_case.skip_reason = cls.skip_reason
560            test_case.required_capabilities = set()
561            test_case.topology_type = cls.topology_type
562            test_case.topology_type.add_to_required(test_case)
563            test_case.test_type = test_case_type
564            return test_case
565
566        return _decorator
567
568
569#: The decorator for functional test cases.
570func_test: Callable = TestCase.make_decorator(TestCaseType.FUNCTIONAL)
571#: The decorator for performance test cases.
572perf_test: Callable = TestCase.make_decorator(TestCaseType.PERFORMANCE)
573
574
575@dataclass
576class TestSuiteSpec:
577    """A class defining the specification of a test suite.
578
579    Apart from defining all the specs of a test suite, a helper function :meth:`discover_all` is
580    provided to automatically discover all the available test suites.
581
582    Attributes:
583        module_name: The name of the test suite's module.
584    """
585
586    #:
587    TEST_SUITES_PACKAGE_NAME = "tests"
588    #:
589    TEST_SUITE_MODULE_PREFIX = "TestSuite_"
590    #:
591    TEST_SUITE_CLASS_PREFIX = "Test"
592    #:
593    TEST_CASE_METHOD_PREFIX = "test_"
594    #:
595    FUNC_TEST_CASE_REGEX = r"test_(?!perf_)"
596    #:
597    PERF_TEST_CASE_REGEX = r"test_perf_"
598
599    module_name: str
600
601    @cached_property
602    def name(self) -> str:
603        """The name of the test suite's module."""
604        return self.module_name[len(self.TEST_SUITE_MODULE_PREFIX) :]
605
606    @cached_property
607    def module(self) -> ModuleType:
608        """A reference to the test suite's module."""
609        return import_module(f"{self.TEST_SUITES_PACKAGE_NAME}.{self.module_name}")
610
611    @cached_property
612    def class_name(self) -> str:
613        """The name of the test suite's class."""
614        return f"{self.TEST_SUITE_CLASS_PREFIX}{to_pascal_case(self.name)}"
615
616    @cached_property
617    def class_obj(self) -> type[TestSuite]:
618        """A reference to the test suite's class."""
619
620        def is_test_suite(obj) -> bool:
621            """Check whether `obj` is a :class:`TestSuite`.
622
623            The `obj` is a subclass of :class:`TestSuite`, but not :class:`TestSuite` itself.
624
625            Args:
626                obj: The object to be checked.
627
628            Returns:
629                :data:`True` if `obj` is a subclass of `TestSuite`.
630            """
631            try:
632                if issubclass(obj, TestSuite) and obj is not TestSuite:
633                    return True
634            except TypeError:
635                return False
636            return False
637
638        for class_name, class_obj in inspect.getmembers(self.module, is_test_suite):
639            if class_name == self.class_name:
640                return class_obj
641
642        raise InternalError(
643            f"Expected class {self.class_name} not found in module {self.module_name}."
644        )
645
646    @classmethod
647    def discover_all(
648        cls, package_name: str | None = None, module_prefix: str | None = None
649    ) -> list[Self]:
650        """Discover all the test suites.
651
652        The test suites are discovered in the provided `package_name`. The full module name,
653        expected under that package, is prefixed with `module_prefix`.
654        The module name is a standard filename with words separated with underscores.
655        For each module found, search for a :class:`TestSuite` class which starts
656        with :attr:`~TestSuiteSpec.TEST_SUITE_CLASS_PREFIX`, continuing with the module name in
657        PascalCase.
658
659        The PascalCase convention applies to abbreviations, acronyms, initialisms and so on::
660
661            OS -> Os
662            TCP -> Tcp
663
664        Args:
665            package_name: The name of the package where to find the test suites. If :data:`None`,
666                the :attr:`~TestSuiteSpec.TEST_SUITES_PACKAGE_NAME` is used.
667            module_prefix: The name prefix defining the test suite module. If :data:`None`, the
668                :attr:`~TestSuiteSpec.TEST_SUITE_MODULE_PREFIX` constant is used.
669
670        Returns:
671            A list containing all the discovered test suites.
672        """
673        if package_name is None:
674            package_name = cls.TEST_SUITES_PACKAGE_NAME
675        if module_prefix is None:
676            module_prefix = cls.TEST_SUITE_MODULE_PREFIX
677
678        test_suites = []
679
680        test_suites_pkg = import_module(package_name)
681        for _, module_name, is_pkg in iter_modules(test_suites_pkg.__path__):
682            if not module_name.startswith(module_prefix) or is_pkg:
683                continue
684
685            test_suite = cls(module_name)
686            try:
687                if test_suite.class_obj:
688                    test_suites.append(test_suite)
689            except InternalError as err:
690                get_dts_logger().warning(err)
691
692        return test_suites
693
694
695AVAILABLE_TEST_SUITES: list[TestSuiteSpec] = TestSuiteSpec.discover_all()
696"""Constant to store all the available, discovered and imported test suites.
697
698The test suites should be gathered from this list to avoid importing more than once.
699"""
700
701
702def find_by_name(name: str) -> TestSuiteSpec | None:
703    """Find a requested test suite by name from the available ones."""
704    test_suites = filter(lambda t: t.name == name, AVAILABLE_TEST_SUITES)
705    return next(test_suites, None)
706