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