From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id 9F670A056B; Wed, 16 Nov 2022 16:15:48 +0100 (CET) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 43FE640E03; Wed, 16 Nov 2022 16:15:48 +0100 (CET) Received: from mail-pl1-f181.google.com (mail-pl1-f181.google.com [209.85.214.181]) by mails.dpdk.org (Postfix) with ESMTP id 1B11440DFB for ; Wed, 16 Nov 2022 16:15:47 +0100 (CET) Received: by mail-pl1-f181.google.com with SMTP id v17so16714437plo.1 for ; Wed, 16 Nov 2022 07:15:47 -0800 (PST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=iol.unh.edu; s=unh-iol; h=cc:to:subject:message-id:date:from:in-reply-to:references :mime-version:from:to:cc:subject:date:message-id:reply-to; bh=aY/tOiWXXIn7NE6lh7PFGQ0IwrFVqC52Igzj2FtImLY=; b=GRlJavCfmkyOc9tjg3g1CbX8COCU4J9/lr5xE18b91XmCmwK6nWdfSwgoeun10EDg6 AWE5tQxjMMN8yj7agmpQdmsd4o4u+Bzfj2L8GjNDTpQc4EBVg+ASDuFhM8miLyU9BVUQ Y1CEDYnthdmvNJDjdClADvgOFoAh5fqvVPYxI= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20210112; h=cc:to:subject:message-id:date:from:in-reply-to:references :mime-version:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=aY/tOiWXXIn7NE6lh7PFGQ0IwrFVqC52Igzj2FtImLY=; b=KPImavgPasYmWWedrx2EyMZB3SWOrdP4dyCNL4grFSVJY33Foh9Dx/X2wiHXNyvFj7 cUf02+6Adt2VxoRgfdikY6gGrKXOC4hrpvt8Syjf5J/V2+aZINc+xtLKOn9xygKYSQk1 tGGDiQk0QCmY1GIeS3cpxwG3N2KaRPVhllS92M9IY365d2YfT4pQJiGVhitgeP22xxuQ JLo90XshWJHU0zWDSpfh4om1JrVEzjhUN3l6sjzIP0/KcPjFXjWq8eKTixETC3W5jMPh 6th1lpSpBG+vhfRx4RvZ9EITIwUNFfOgHPAzZmmqdq1Vy43qdzIk48w0cKxFbz+BcML8 KOUw== X-Gm-Message-State: ANoB5pkjz522NZsYgANE4On8fjagQjieET5aZMxbtIAcd471JXV8Webm hYPmkLXZzDyCTq6LlvReWNyMm7nCz5mwqTmj7Lz20Q== X-Google-Smtp-Source: AA0mqf5xVL08N5yZsgFNcXf1Expds/B4cTE+BSFGKxZJ2Rv4LJaFM4oau4qg+GmP4cqXZ5PzNqGqU+4KdM+2MMjld0g= X-Received: by 2002:a17:902:f114:b0:186:ac81:2aa9 with SMTP id e20-20020a170902f11400b00186ac812aa9mr9444587plb.95.1668611746141; Wed, 16 Nov 2022 07:15:46 -0800 (PST) MIME-Version: 1.0 References: <20220824162454.394285-1-juraj.linkes@pantheon.tech> <20221114165438.1133783-1-juraj.linkes@pantheon.tech> <20221114165438.1133783-9-juraj.linkes@pantheon.tech> In-Reply-To: <20221114165438.1133783-9-juraj.linkes@pantheon.tech> From: Owen Hilyard Date: Wed, 16 Nov 2022 10:15:10 -0500 Message-ID: Subject: Re: [RFC PATCH v2 08/10] dts: add testsuite class To: =?UTF-8?Q?Juraj_Linke=C5=A1?= Cc: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, bruce.richardson@intel.com, dev@dpdk.org Content-Type: multipart/alternative; boundary="00000000000065b68705ed97f2bb" X-BeenThere: dev@dpdk.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: DPDK patches and discussions List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: dev-bounces@dpdk.org --00000000000065b68705ed97f2bb Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: quoted-printable On Mon, Nov 14, 2022 at 11:54 AM Juraj Linke=C5=A1 wrote: > This is the base class that all test suites inherit from. The base class > implements methods common to all test suites. The derived test suites > implement tests and any particular setup needed for the suite or tests. > > Signed-off-by: Juraj Linke=C5=A1 > --- > dts/conf.yaml | 4 + > dts/framework/config/__init__.py | 33 ++- > dts/framework/config/conf_yaml_schema.json | 49 ++++ > dts/framework/dts.py | 29 +++ > dts/framework/exception.py | 65 ++++++ > dts/framework/settings.py | 25 +++ > dts/framework/test_case.py | 246 +++++++++++++++++++++ > 7 files changed, 450 insertions(+), 1 deletion(-) > create mode 100644 dts/framework/test_case.py > > diff --git a/dts/conf.yaml b/dts/conf.yaml > index 976888a88e..0b0f2c59b0 100644 > --- a/dts/conf.yaml > +++ b/dts/conf.yaml > @@ -7,6 +7,10 @@ executions: > os: linux > cpu: native > compiler: gcc > + perf: false > + func: true > + test_suites: > + - hello_world > system_under_test: "SUT 1" > nodes: > - name: "SUT 1" > diff --git a/dts/framework/config/__init__.py > b/dts/framework/config/__init__.py > index 344d697a69..8874b10030 100644 > --- a/dts/framework/config/__init__.py > +++ b/dts/framework/config/__init__.py > @@ -11,7 +11,7 @@ > import pathlib > from dataclasses import dataclass > from enum import Enum, auto, unique > -from typing import Any, Iterable > +from typing import Any, Iterable, TypedDict > > import warlock # type: ignore > import yaml > @@ -186,9 +186,34 @@ def from_dict(d: dict) -> "BuildTargetConfiguration"= : > ) > > > +class TestSuiteConfigDict(TypedDict): > + suite: str > + cases: list[str] > + > + > +@dataclass(slots=3DTrue, frozen=3DTrue) > +class TestSuiteConfig: > + test_suite: str > + test_cases: list[str] > + > + @staticmethod > + def from_dict( > + entry: str | TestSuiteConfigDict, > + ) -> "TestSuiteConfig": > + if isinstance(entry, str): > + return TestSuiteConfig(test_suite=3Dentry, test_cases=3D[]) > + elif isinstance(entry, dict): > + return TestSuiteConfig(test_suite=3Dentry["suite"], > test_cases=3Dentry["cases"]) > + else: > + raise TypeError(f"{type(entry)} is not valid for a test suit= e > config.") > + > + > @dataclass(slots=3DTrue, frozen=3DTrue) > class ExecutionConfiguration: > build_targets: list[BuildTargetConfiguration] > + perf: bool > + func: bool > + test_suites: list[TestSuiteConfig] > system_under_test: NodeConfiguration > > @staticmethod > @@ -196,11 +221,17 @@ def from_dict(d: dict, node_map: dict) -> > "ExecutionConfiguration": > build_targets: list[BuildTargetConfiguration] =3D list( > map(BuildTargetConfiguration.from_dict, d["build_targets"]) > ) > + test_suites: list[TestSuiteConfig] =3D list( > + map(TestSuiteConfig.from_dict, d["test_suites"]) > + ) > sut_name =3D d["system_under_test"] > assert sut_name in node_map, f"Unknown SUT {sut_name} in > execution {d}" > > return ExecutionConfiguration( > build_targets=3Dbuild_targets, > + perf=3Dd["perf"], > + func=3Dd["func"], > + test_suites=3Dtest_suites, > system_under_test=3Dnode_map[sut_name], > ) > > diff --git a/dts/framework/config/conf_yaml_schema.json > b/dts/framework/config/conf_yaml_schema.json > index c59d3e30e6..e37ced65fe 100644 > --- a/dts/framework/config/conf_yaml_schema.json > +++ b/dts/framework/config/conf_yaml_schema.json > @@ -63,6 +63,31 @@ > } > }, > "additionalProperties": false > + }, > + "test_suite": { > + "type": "string", > + "enum": [ > + "hello_world" > + ] > + }, > + "test_target": { > + "type": "object", > + "properties": { > + "suite": { > + "$ref": "#/definitions/test_suite" > + }, > + "cases": { > + "type": "array", > + "items": { > + "type": "string" > + }, > + "minimum": 1 > + } > + }, > + "required": [ > + "suite" > + ], > + "additionalProperties": false > } > }, > "type": "object", > @@ -130,6 +155,27 @@ > }, > "minimum": 1 > }, > + "perf": { > + "type": "boolean", > + "description": "Enable performance testing" > + }, > + "func": { > + "type": "boolean", > + "description": "Enable functional testing" > + }, > + "test_suites": { > + "type": "array", > + "items": { > + "oneOf": [ > + { > + "$ref": "#/definitions/test_suite" > + }, > + { > + "$ref": "#/definitions/test_target" > + } > + ] > + } > + }, > "system_under_test": { > "$ref": "#/definitions/node_name" > } > @@ -137,6 +183,9 @@ > "additionalProperties": false, > "required": [ > "build_targets", > + "perf", > + "func", > + "test_suites", > "system_under_test" > ] > }, > diff --git a/dts/framework/dts.py b/dts/framework/dts.py > index a7c243a5c3..ba3f4b4168 100644 > --- a/dts/framework/dts.py > +++ b/dts/framework/dts.py > @@ -15,6 +15,7 @@ > from .logger import DTSLOG, getLogger > from .settings import SETTINGS > from .stats_reporter import TestStats > +from .test_case import TestCase > from .test_result import Result > from .utils import check_dts_python_version > > @@ -129,6 +130,34 @@ def run_suite( > Use the given build_target to run the test suite with possibly only = a > subset > of tests. If no subset is specified, run all tests. > """ > + for test_suite_config in execution.test_suites: > + result.test_suite =3D test_suite_config.test_suite > + full_suite_path =3D > f"tests.TestSuite_{test_suite_config.test_suite}" > + testcase_classes =3D TestCase.get_testcases(full_suite_path) > + dts_logger.debug( > + f"Found testcase classes '{testcase_classes}' in > '{full_suite_path}'" > + ) > + for testcase_class in testcase_classes: > + testcase =3D testcase_class( > + sut_node, test_suite_config.test_suite, build_target, > execution > + ) > + > + testcase.init_log() > + testcase.set_requested_cases(SETTINGS.test_cases) > + testcase.set_requested_cases(test_suite_config.test_cases) > + > + dts_logger.info(f"Running test suite > '{testcase_class.__name__}'") > + try: > + testcase.execute_setup_all() > + testcase.execute_test_cases() > + dts_logger.info( > + f"Finished running test suite > '{testcase_class.__name__}'" > + ) > + result.copy_suite(testcase.get_result()) > + test_stats.save(result) # this was originally after > teardown > + > + finally: > You should probably move the "finished" log message down here, so that it always runs. > + testcase.execute_tear_downall() > > > def quit_execution(nodes: Iterable[Node], return_code: ReturnCode) -> > None: > diff --git a/dts/framework/exception.py b/dts/framework/exception.py > index 93d99432ae..a35eeff640 100644 > --- a/dts/framework/exception.py > +++ b/dts/framework/exception.py > @@ -29,6 +29,10 @@ class ReturnCode(IntEnum): > DPDK_BUILD_ERR =3D 10 > NODE_SETUP_ERR =3D 20 > NODE_CLEANUP_ERR =3D 21 > + SUITE_SETUP_ERR =3D 30 > + SUITE_EXECUTION_ERR =3D 31 > + TESTCASE_VERIFY_ERR =3D 32 > + SUITE_CLEANUP_ERR =3D 33 > > > class DTSError(Exception): > @@ -153,6 +157,67 @@ def __init__(self): > ) > > > +class TestSuiteNotFound(DTSError): > + """ > + Raised when a configured test suite cannot be imported. > + """ > + > + return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_SETUP_ERR > + > + > +class SuiteSetupError(DTSError): > + """ > + Raised when an error occurs during suite setup. > + """ > + > + return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_SETUP_ERR > + > + def __init__(self): > + super(SuiteSetupError, self).__init__("An error occurred during > suite setup.") > + > + > +class SuiteExecutionError(DTSError): > + """ > + Raised when an error occurs during suite execution. > + """ > + > + return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_EXECUTION_ERR > + > + def __init__(self): > + super(SuiteExecutionError, self).__init__( > + "An error occurred during suite execution." > + ) > + > + > +class VerifyError(DTSError): > + """ > + To be used within the test cases to verify if a command output > + is as it was expected. > + """ > + > + value: str > + return_code: ClassVar[ReturnCode] =3D ReturnCode.TESTCASE_VERIFY_ERR > + > + def __init__(self, value: str): > + self.value =3D value > + > + def __str__(self) -> str: > + return repr(self.value) > + > + > +class SuiteCleanupError(DTSError): > + """ > + Raised when an error occurs during suite cleanup. > + """ > + > + return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_CLEANUP_ERR > + > + def __init__(self): > + super(SuiteCleanupError, self).__init__( > + "An error occurred during suite cleanup." > + ) > + > + > def convert_exception(exception: type[DTSError]) -> Callable[..., > Callable[..., None]]: > """ > When a non-DTS exception is raised while executing the decorated > function, > diff --git a/dts/framework/settings.py b/dts/framework/settings.py > index e2bf3d2ce4..069f28ce81 100644 > --- a/dts/framework/settings.py > +++ b/dts/framework/settings.py > @@ -64,6 +64,8 @@ class _Settings: > skip_setup: bool > dpdk_ref: Path > compile_timeout: float > + test_cases: list > + re_run: int > > > def _get_parser() -> argparse.ArgumentParser: > @@ -138,6 +140,25 @@ def _get_parser() -> argparse.ArgumentParser: > help=3D"[DTS_COMPILE_TIMEOUT] The timeout for compiling DPDK.", > ) > > + parser.add_argument( > + "--test-cases", > + action=3D_env_arg("DTS_TESTCASES"), > + default=3D"", > + required=3DFalse, > + help=3D"[DTS_TESTCASES] Comma-separated list of testcases to > execute", > + ) > + > + parser.add_argument( > + "--re-run", > + "--re_run", > + action=3D_env_arg("DTS_RERUN"), > + default=3D0, > + type=3Dint, > + required=3DFalse, > + help=3D"[DTS_RERUN] Re-run tests the specified amount of times i= f a > test failure " > + "occurs", > + ) > + > return parser > > > @@ -151,6 +172,10 @@ def _get_settings() -> _Settings: > skip_setup=3D(parsed_args.skip_setup =3D=3D "Y"), > dpdk_ref=3Dparsed_args.dpdk_ref, > compile_timeout=3Dparsed_args.compile_timeout, > + test_cases=3Dparsed_args.test_cases.split(",") > + if parsed_args.test_cases !=3D "" > + else [], > + re_run=3Dparsed_args.re_run, > ) > > > diff --git a/dts/framework/test_case.py b/dts/framework/test_case.py > new file mode 100644 > index 0000000000..0479f795bb > --- /dev/null > +++ b/dts/framework/test_case.py > @@ -0,0 +1,246 @@ > +# SPDX-License-Identifier: BSD-3-Clause > +# Copyright(c) 2010-2014 Intel Corporation > +# Copyright(c) 2022 PANTHEON.tech s.r.o. > + > +""" > +A base class for creating DTS test cases. > +""" > + > +import importlib > +import inspect > +import re > +import time > +import traceback > + > +from .exception import ( > + SSHTimeoutError, > + SuiteCleanupError, > + SuiteExecutionError, > + SuiteSetupError, > + TestSuiteNotFound, > + VerifyError, > + convert_exception, > +) > +from .logger import getLogger > +from .settings import SETTINGS > +from .test_result import Result > + > + > +class TestCase(object): > + def __init__(self, sut_node, suitename, target, execution): > + self.sut_node =3D sut_node > + self.suite_name =3D suitename > + self.target =3D target > + > + # local variable > + self._requested_tests =3D [] > + self._subtitle =3D None > + > + # result object for save suite result > + self._suite_result =3D Result() > + self._suite_result.sut =3D self.sut_node.config.hostname > + self._suite_result.target =3D target > + self._suite_result.test_suite =3D self.suite_name > + if self._suite_result is None: > + raise ValueError("Result object should not None") > + > + self._enable_func =3D execution.func > + > + # command history > + self.setup_history =3D list() > + self.test_history =3D list() > + > + def init_log(self): > + # get log handler > + class_name =3D self.__class__.__name__ > + self.logger =3D getLogger(class_name) > + > + def set_up_all(self): > + pass > + > + def set_up(self): > + pass > + > + def tear_down(self): > + pass > + > + def tear_down_all(self): > + pass > + > + def verify(self, passed, description): > + if not passed: > + raise VerifyError(description) > + > + def _get_functional_cases(self): > + """ > + Get all functional test cases. > + """ > + return self._get_test_cases(r"test_(?!perf_)") > + > + def _has_it_been_requested(self, test_case, test_name_regex): > + """ > + Check whether test case has been requested for validation. > + """ > + name_matches =3D re.match(test_name_regex, test_case.__name__) > + if self._requested_tests: > + return name_matches and test_case.__name__ in > self._requested_tests > + > + return name_matches > + > + def set_requested_cases(self, case_list): > + """ > + Pass down input cases list for check > + """ > + self._requested_tests +=3D case_list > + > + def _get_test_cases(self, test_name_regex): > + """ > + Return case list which name matched regex. > + """ > + self.logger.debug(f"Searching for testcases in {self.__class__}"= ) > + for test_case_name in dir(self): > + test_case =3D getattr(self, test_case_name) > + if callable(test_case) and self._has_it_been_requested( > + test_case, test_name_regex > + ): > + yield test_case > + > + @convert_exception(SuiteSetupError) > + def execute_setup_all(self): > + """ > + Execute suite setup_all function before cases. > + """ > + try: > + self.set_up_all() > + return True > + except Exception as v: > + self.logger.error("set_up_all failed:\n" + > traceback.format_exc()) > + # record all cases blocked > + if self._enable_func: > + for case_obj in self._get_functional_cases(): > + self._suite_result.test_case =3D case_obj.__name__ > + self._suite_result.test_case_blocked( > + "set_up_all failed: {}".format(str(v)) > + ) > + return False > + > + def _execute_test_case(self, case_obj): > + """ > + Execute specified test case in specified suite. If any exception > occurred in > + validation process, save the result and tear down this case. > + """ > + case_name =3D case_obj.__name__ > + self._suite_result.test_case =3D case_obj.__name__ > + > + case_result =3D True > + try: > + self.logger.info("Test Case %s Begin" % case_name) > + > + self.running_case =3D case_name > + # run set_up function for each case > + self.set_up() > + # run test case > + case_obj() > + > + self._suite_result.test_case_passed() > + > + self.logger.info("Test Case %s Result PASSED:" % case_name) > + > + except VerifyError as v: > + case_result =3D False > + self._suite_result.test_case_failed(str(v)) > + self.logger.error("Test Case %s Result FAILED: " % > (case_name) + str(v)) > + except KeyboardInterrupt: > + self._suite_result.test_case_blocked("Skipped") > + self.logger.error("Test Case %s SKIPPED: " % (case_name)) > + self.tear_down() > + raise KeyboardInterrupt("Stop DTS") > + except SSHTimeoutError as e: > + case_result =3D False > + self._suite_result.test_case_failed(str(e)) > + self.logger.error("Test Case %s Result FAILED: " % > (case_name) + str(e)) > + self.logger.error("%s" % (e.get_output())) > + except Exception: > + case_result =3D False > + trace =3D traceback.format_exc() > + self._suite_result.test_case_failed(trace) > + self.logger.error("Test Case %s Result ERROR: " % (case_name= ) > + trace) > + finally: > + self.execute_tear_down() > + return case_result > + > + @convert_exception(SuiteExecutionError) > + def execute_test_cases(self): > + """ > + Execute all test cases in one suite. > + """ > + # prepare debugger rerun case environment > + if self._enable_func: > + for case_obj in self._get_functional_cases(): > + for i in range(SETTINGS.re_run + 1): > + ret =3D self.execute_test_case(case_obj) > + > + if ret is False and SETTINGS.re_run: > + self.sut_node.get_session_output(timeout=3D0.5 *= (i > + 1)) > + time.sleep(i + 1) > + self.logger.info( > + " Test case %s failed and re-run %d time" > + % (case_obj.__name__, i + 1) > + ) > + else: > + break > + > + def execute_test_case(self, case_obj): > + """ > + Execute test case or enter into debug mode. > + """ > + return self._execute_test_case(case_obj) > + > + def get_result(self): > + """ > + Return suite test result > + """ > + return self._suite_result > + > + @convert_exception(SuiteCleanupError) > + def execute_tear_downall(self): > + """ > + execute suite tear_down_all function > + """ > + self.tear_down_all() > + > + self.sut_node.kill_cleanup_dpdk_apps() > + > + def execute_tear_down(self): > + """ > + execute suite tear_down function > + """ > + try: > + self.tear_down() > + except Exception: > + self.logger.error("tear_down failed:\n" + > traceback.format_exc()) > + self.logger.warning( > + "tear down %s failed, might iterfere next case's result!= " > + % self.running_case > + ) > + > + @staticmethod > + def get_testcases(testsuite_module_path: str) -> > list[type["TestCase"]]: > + def is_testcase(object) -> bool: > + try: > + if issubclass(object, TestCase) and object !=3D TestCase= : > + return True > + except TypeError: > + return False > + return False > + > + try: > + testcase_module =3D > importlib.import_module(testsuite_module_path) > + except ModuleNotFoundError as e: > + raise TestSuiteNotFound( > + f"Testsuite '{testsuite_module_path}' not found." > + ) from e > + return [ > + testcase_class > + for _, testcase_class in inspect.getmembers(testcase_module, > is_testcase) > + ] > -- > 2.30.2 > > --00000000000065b68705ed97f2bb Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable
On Mon, Nov 14, 2022 at 11:54 AM Juraj Li= nke=C5=A1 <juraj.linkes@pantheon.tech> wrote:
This is the = base class that all test suites inherit from. The base class
implements methods common to all test suites. The derived test suites
implement tests and any particular setup needed for the suite or tests.

Signed-off-by: Juraj Linke=C5=A1 <juraj.linkes@pantheon.tech>
---
=C2=A0dts/conf.yaml=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 |=C2=A0 =C2=A04 +
=C2=A0dts/framework/config/__init__.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0|=C2=A0 33 ++-
=C2=A0dts/framework/config/conf_yaml_schema.json |=C2=A0 49 ++++
=C2=A0dts/framework/dts.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0|=C2=A0 29 +++
=C2=A0dts/framework/exception.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0|=C2=A0 65 ++++++
=C2=A0dts/framework/settings.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 |=C2=A0 25 +++
=C2=A0dts/framework/test_case.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0| 246 +++++++++++++++++++++
=C2=A07 files changed, 450 insertions(+), 1 deletion(-)
=C2=A0create mode 100644 dts/framework/test_case.py

diff --git a/dts/conf.yaml b/dts/conf.yaml
index 976888a88e..0b0f2c59b0 100644
--- a/dts/conf.yaml
+++ b/dts/conf.yaml
@@ -7,6 +7,10 @@ executions:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0os: linux
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0cpu: native
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0compiler: gcc
+=C2=A0 =C2=A0 perf: false
+=C2=A0 =C2=A0 func: true
+=C2=A0 =C2=A0 test_suites:
+=C2=A0 =C2=A0 =C2=A0 - hello_world
=C2=A0 =C2=A0 =C2=A0system_under_test: "SUT 1"
=C2=A0nodes:
=C2=A0 =C2=A0- name: "SUT 1"
diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init= __.py
index 344d697a69..8874b10030 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -11,7 +11,7 @@
=C2=A0import pathlib
=C2=A0from dataclasses import dataclass
=C2=A0from enum import Enum, auto, unique
-from typing import Any, Iterable
+from typing import Any, Iterable, TypedDict

=C2=A0import warlock=C2=A0 # type: ignore
=C2=A0import yaml
@@ -186,9 +186,34 @@ def from_dict(d: dict) -> "BuildTargetConfigur= ation":
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0)


+class TestSuiteConfigDict(TypedDict):
+=C2=A0 =C2=A0 suite: str
+=C2=A0 =C2=A0 cases: list[str]
+
+
+@dataclass(slots=3DTrue, frozen=3DTrue)
+class TestSuiteConfig:
+=C2=A0 =C2=A0 test_suite: str
+=C2=A0 =C2=A0 test_cases: list[str]
+
+=C2=A0 =C2=A0 @staticmethod
+=C2=A0 =C2=A0 def from_dict(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 entry: str | TestSuiteConfigDict,
+=C2=A0 =C2=A0 ) -> "TestSuiteConfig":
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 if isinstance(entry, str):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return TestSuiteConfig(test_suit= e=3Dentry, test_cases=3D[])
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 elif isinstance(entry, dict):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return TestSuiteConfig(test_suit= e=3Dentry["suite"], test_cases=3Dentry["cases"])
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 else:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 raise TypeError(f"{type(ent= ry)} is not valid for a test suite config.")
+
+
=C2=A0@dataclass(slots=3DTrue, frozen=3DTrue)
=C2=A0class ExecutionConfiguration:
=C2=A0 =C2=A0 =C2=A0build_targets: list[BuildTargetConfiguration]
+=C2=A0 =C2=A0 perf: bool
+=C2=A0 =C2=A0 func: bool
+=C2=A0 =C2=A0 test_suites: list[TestSuiteConfig]
=C2=A0 =C2=A0 =C2=A0system_under_test: NodeConfiguration

=C2=A0 =C2=A0 =C2=A0@staticmethod
@@ -196,11 +221,17 @@ def from_dict(d: dict, node_map: dict) -> "Ex= ecutionConfiguration":
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0build_targets: list[BuildTargetConfigurat= ion] =3D list(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0map(BuildTargetConfiguratio= n.from_dict, d["build_targets"])
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 test_suites: list[TestSuiteConfig] =3D list( +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 map(TestSuiteConfig.from_dict, d= ["test_suites"])
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0sut_name =3D d["system_under_test&qu= ot;]
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0assert sut_name in node_map, f"Unkno= wn SUT {sut_name} in execution {d}"

=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0return ExecutionConfiguration(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0build_targets=3Dbuild_targe= ts,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 perf=3Dd["perf"],
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 func=3Dd["func"],
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 test_suites=3Dtest_suites,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0system_under_test=3Dnode_ma= p[sut_name],
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0)

diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/con= fig/conf_yaml_schema.json
index c59d3e30e6..e37ced65fe 100644
--- a/dts/framework/config/conf_yaml_schema.json
+++ b/dts/framework/config/conf_yaml_schema.json
@@ -63,6 +63,31 @@
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0}
=C2=A0 =C2=A0 =C2=A0 =C2=A0},
=C2=A0 =C2=A0 =C2=A0 =C2=A0"additionalProperties": false
+=C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 "test_suite": {
+=C2=A0 =C2=A0 =C2=A0 "type": "string",
+=C2=A0 =C2=A0 =C2=A0 "enum": [
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "hello_world"
+=C2=A0 =C2=A0 =C2=A0 ]
+=C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 "test_target": {
+=C2=A0 =C2=A0 =C2=A0 "type": "object",
+=C2=A0 =C2=A0 =C2=A0 "properties": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "suite": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "$ref": "#/definitions/t= est_suite"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "cases": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "type": "array", +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "items": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "type": "string&q= uot;
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "minimum": 1
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 }
+=C2=A0 =C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 =C2=A0 "required": [
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "suite"
+=C2=A0 =C2=A0 =C2=A0 ],
+=C2=A0 =C2=A0 =C2=A0 "additionalProperties": false
=C2=A0 =C2=A0 =C2=A0}
=C2=A0 =C2=A0},
=C2=A0 =C2=A0"type": "object",
@@ -130,6 +155,27 @@
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0},
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"minimum": 1
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0},
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "perf": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "type": "boolean&= quot;,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "description": "E= nable performance testing"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "func": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "type": "boolean&= quot;,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "description": "E= nable functional testing"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "test_suites": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "type": "array&qu= ot;,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "items": {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "oneOf": [
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "$ref&= quot;: "#/definitions/test_suite"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 {
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "$ref&= quot;: "#/definitions/test_target"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 }
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ]
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 }
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 },
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"system_under_test": { =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"$ref": "#/d= efinitions/node_name"
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0}
@@ -137,6 +183,9 @@
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"additionalProperties": false,<= br> =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"required": [
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"build_targets",
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "perf",
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "func",
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "test_suites",
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"system_under_test"
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0]
=C2=A0 =C2=A0 =C2=A0 =C2=A0},
diff --git a/dts/framework/dts.py b/dts/framework/dts.py
index a7c243a5c3..ba3f4b4168 100644
--- a/dts/framework/dts.py
+++ b/dts/framework/dts.py
@@ -15,6 +15,7 @@
=C2=A0from .logger import DTSLOG, getLogger
=C2=A0from .settings import SETTINGS
=C2=A0from .stats_reporter import TestStats
+from .test_case import TestCase
=C2=A0from .test_result import Result
=C2=A0from .utils import check_dts_python_version

@@ -129,6 +130,34 @@ def run_suite(
=C2=A0 =C2=A0 =C2=A0Use the given build_target to run the test suite with p= ossibly only a subset
=C2=A0 =C2=A0 =C2=A0of tests. If no subset is specified, run all tests.
=C2=A0 =C2=A0 =C2=A0"""
+=C2=A0 =C2=A0 for test_suite_config in execution.test_suites:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 result.test_suite =3D test_suite_config.test_s= uite
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 full_suite_path =3D f"tests.TestSuite_{te= st_suite_config.test_suite}"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase_classes =3D TestCase.get_testcases(fu= ll_suite_path)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 dts_logger.debug(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"Found testcase classes &#= 39;{testcase_classes}' in '{full_suite_path}'"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 for testcase_class in testcase_classes:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase =3D testcase_class(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_node, test_sui= te_config.test_suite, build_target, execution
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase.init_log()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase.set_requested_cases(SET= TINGS.test_cases)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase.set_requested_cases(tes= t_suite_config.test_cases)
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 dts_logger.info(f"Running = test suite '{testcase_class.__name__}'")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase.execute_s= etup_all()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase.execute_t= est_cases()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 dts_logger.info(<= br> +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f&qu= ot;Finished running test suite '{testcase_class.__name__}'" +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 result.copy_suite(= testcase.get_result())
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 test_stats.save(re= sult)=C2=A0 # this was originally after teardown
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 finally:
You should probably move the "finished" log message = down here, so that it always runs.
=C2=A0
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase.execute_t= ear_downall()


=C2=A0def quit_execution(nodes: Iterable[Node], return_code: ReturnCode) -&= gt; None:
diff --git a/dts/framework/exception.py b/dts/framework/exception.py
index 93d99432ae..a35eeff640 100644
--- a/dts/framework/exception.py
+++ b/dts/framework/exception.py
@@ -29,6 +29,10 @@ class ReturnCode(IntEnum):
=C2=A0 =C2=A0 =C2=A0DPDK_BUILD_ERR =3D 10
=C2=A0 =C2=A0 =C2=A0NODE_SETUP_ERR =3D 20
=C2=A0 =C2=A0 =C2=A0NODE_CLEANUP_ERR =3D 21
+=C2=A0 =C2=A0 SUITE_SETUP_ERR =3D 30
+=C2=A0 =C2=A0 SUITE_EXECUTION_ERR =3D 31
+=C2=A0 =C2=A0 TESTCASE_VERIFY_ERR =3D 32
+=C2=A0 =C2=A0 SUITE_CLEANUP_ERR =3D 33


=C2=A0class DTSError(Exception):
@@ -153,6 +157,67 @@ def __init__(self):
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0)


+class TestSuiteNotFound(DTSError):
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 Raised when a configured test suite cannot be imported.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_SETUP= _ERR
+
+
+class SuiteSetupError(DTSError):
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 Raised when an error occurs during suite setup.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_SETUP= _ERR
+
+=C2=A0 =C2=A0 def __init__(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 super(SuiteSetupError, self).__init__("An= error occurred during suite setup.")
+
+
+class SuiteExecutionError(DTSError):
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 Raised when an error occurs during suite execution.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_EXECU= TION_ERR
+
+=C2=A0 =C2=A0 def __init__(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 super(SuiteExecutionError, self).__init__(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "An error occurred during s= uite execution."
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+
+class VerifyError(DTSError):
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 To be used within the test cases to verify if a command outp= ut
+=C2=A0 =C2=A0 is as it was expected.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 value: str
+=C2=A0 =C2=A0 return_code: ClassVar[ReturnCode] =3D ReturnCode.TESTCASE_VE= RIFY_ERR
+
+=C2=A0 =C2=A0 def __init__(self, value: str):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.value =3D value
+
+=C2=A0 =C2=A0 def __str__(self) -> str:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return repr(self.value)
+
+
+class SuiteCleanupError(DTSError):
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 Raised when an error occurs during suite cleanup.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 return_code: ClassVar[ReturnCode] =3D ReturnCode.SUITE_CLEAN= UP_ERR
+
+=C2=A0 =C2=A0 def __init__(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 super(SuiteCleanupError, self).__init__(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "An error occurred during s= uite cleanup."
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+
=C2=A0def convert_exception(exception: type[DTSError]) -> Callable[..., = Callable[..., None]]:
=C2=A0 =C2=A0 =C2=A0"""
=C2=A0 =C2=A0 =C2=A0When a non-DTS exception is raised while executing the = decorated function,
diff --git a/dts/framework/settings.py b/dts/framework/settings.py
index e2bf3d2ce4..069f28ce81 100644
--- a/dts/framework/settings.py
+++ b/dts/framework/settings.py
@@ -64,6 +64,8 @@ class _Settings:
=C2=A0 =C2=A0 =C2=A0skip_setup: bool
=C2=A0 =C2=A0 =C2=A0dpdk_ref: Path
=C2=A0 =C2=A0 =C2=A0compile_timeout: float
+=C2=A0 =C2=A0 test_cases: list
+=C2=A0 =C2=A0 re_run: int


=C2=A0def _get_parser() -> argparse.ArgumentParser:
@@ -138,6 +140,25 @@ def _get_parser() -> argparse.ArgumentParser:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0help=3D"[DTS_COMPILE_TIMEOUT] The ti= meout for compiling DPDK.",
=C2=A0 =C2=A0 =C2=A0)

+=C2=A0 =C2=A0 parser.add_argument(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "--test-cases",
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 action=3D_env_arg("DTS_TESTCASES"),<= br> +=C2=A0 =C2=A0 =C2=A0 =C2=A0 default=3D"",
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 required=3DFalse,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 help=3D"[DTS_TESTCASES] Comma-separated l= ist of testcases to execute",
+=C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 parser.add_argument(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "--re-run",
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "--re_run",
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 action=3D_env_arg("DTS_RERUN"),
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 default=3D0,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 type=3Dint,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 required=3DFalse,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 help=3D"[DTS_RERUN] Re-run tests the spec= ified amount of times if a test failure "
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 "occurs",
+=C2=A0 =C2=A0 )
+
=C2=A0 =C2=A0 =C2=A0return parser


@@ -151,6 +172,10 @@ def _get_settings() -> _Settings:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0skip_setup=3D(parsed_args.skip_setup =3D= =3D "Y"),
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0dpdk_ref=3Dparsed_args.dpdk_ref,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0compile_timeout=3Dparsed_args.compile_tim= eout,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 test_cases=3Dparsed_args.test_cases.split(&quo= t;,")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 if parsed_args.test_cases !=3D "" +=C2=A0 =C2=A0 =C2=A0 =C2=A0 else [],
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 re_run=3Dparsed_args.re_run,
=C2=A0 =C2=A0 =C2=A0)


diff --git a/dts/framework/test_case.py b/dts/framework/test_case.py
new file mode 100644
index 0000000000..0479f795bb
--- /dev/null
+++ b/dts/framework/test_case.py
@@ -0,0 +1,246 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2010-2014 Intel Corporation
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+
+"""
+A base class for creating DTS test cases.
+"""
+
+import importlib
+import inspect
+import re
+import time
+import traceback
+
+from .exception import (
+=C2=A0 =C2=A0 SSHTimeoutError,
+=C2=A0 =C2=A0 SuiteCleanupError,
+=C2=A0 =C2=A0 SuiteExecutionError,
+=C2=A0 =C2=A0 SuiteSetupError,
+=C2=A0 =C2=A0 TestSuiteNotFound,
+=C2=A0 =C2=A0 VerifyError,
+=C2=A0 =C2=A0 convert_exception,
+)
+from .logger import getLogger
+from .settings import SETTINGS
+from .test_result import Result
+
+
+class TestCase(object):
+=C2=A0 =C2=A0 def __init__(self, sut_node, suitename, target, execution):<= br> +=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.sut_node =3D sut_node
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.suite_name =3D suitename
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.target =3D target
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 # local variable
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._requested_tests =3D []
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._subtitle =3D None
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 # result object for save suite result
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result =3D Result()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.sut =3D self.sut_node.confi= g.hostname
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.target =3D target
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.test_suite =3D self.suite_n= ame
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 if self._suite_result is None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 raise ValueError("Result ob= ject should not None")
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._enable_func =3D execution.func
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 # command history
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.setup_history =3D list()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.test_history =3D list()
+
+=C2=A0 =C2=A0 def init_log(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 # get log handler
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 class_name =3D self.__class__.__name__
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger =3D getLogger(class_name)
+
+=C2=A0 =C2=A0 def set_up_all(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 pass
+
+=C2=A0 =C2=A0 def set_up(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 pass
+
+=C2=A0 =C2=A0 def tear_down(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 pass
+
+=C2=A0 =C2=A0 def tear_down_all(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 pass
+
+=C2=A0 =C2=A0 def verify(self, passed, description):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 if not passed:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 raise VerifyError(description) +
+=C2=A0 =C2=A0 def _get_functional_cases(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Get all functional test cases.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return self._get_test_cases(r"test_(?!per= f_)")
+
+=C2=A0 =C2=A0 def _has_it_been_requested(self, test_case, test_name_regex)= :
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Check whether test case has been requested for= validation.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 name_matches =3D re.match(test_name_regex, tes= t_case.__name__)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 if self._requested_tests:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return name_matches and test_cas= e.__name__ in self._requested_tests
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return name_matches
+
+=C2=A0 =C2=A0 def set_requested_cases(self, case_list):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Pass down input cases list for check
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._requested_tests +=3D case_list
+
+=C2=A0 =C2=A0 def _get_test_cases(self, test_name_regex):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Return case list which name matched regex.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.debug(f"Searching for testcas= es in {self.__class__}")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 for test_case_name in dir(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 test_case =3D getattr(self, test= _case_name)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if callable(test_case) and self.= _has_it_been_requested(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 test_case, test_na= me_regex
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 yield test_case +
+=C2=A0 =C2=A0 @convert_exception(SuiteSetupError)
+=C2=A0 =C2=A0 def execute_setup_all(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Execute suite setup_all function before cases.=
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.set_up_all()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return True
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 except Exception as v:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.error("set_up_a= ll failed:\n" + traceback.format_exc())
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 # record all cases blocked
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if self._enable_func:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 for case_obj in se= lf._get_functional_cases():
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self= ._suite_result.test_case =3D case_obj.__name__
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self= ._suite_result.test_case_blocked(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 "set_up_all failed: {}".format(str(v))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ) +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return False
+
+=C2=A0 =C2=A0 def _execute_test_case(self, case_obj):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Execute specified test case in specified suite= . If any exception occurred in
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 validation process, save the result and tear d= own this case.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 case_name =3D case_obj.__name__
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.test_case =3D case_obj.__na= me__
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 case_result =3D True
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.info("Test Ca= se %s Begin" % case_name)
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.running_case =3D case_name<= br> +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 # run set_up function for each c= ase
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.set_up()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 # run test case
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 case_obj()
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.test_case_pas= sed()
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.info("Test Ca= se %s Result PASSED:" % case_name)
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 except VerifyError as v:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 case_result =3D False
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.test_case_fai= led(str(v))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.error("Test Cas= e %s Result FAILED: " % (case_name) + str(v))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 except KeyboardInterrupt:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.test_case_blo= cked("Skipped")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.error("Test Cas= e %s SKIPPED: " % (case_name))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.tear_down()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 raise KeyboardInterrupt("St= op DTS")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 except SSHTimeoutError as e:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 case_result =3D False
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.test_case_fai= led(str(e))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.error("Test Cas= e %s Result FAILED: " % (case_name) + str(e))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.error("%s"= % (e.get_output()))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 except Exception:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 case_result =3D False
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 trace =3D traceback.format_exc()=
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._suite_result.test_case_fai= led(trace)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.error("Test Cas= e %s Result ERROR: " % (case_name) + trace)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 finally:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.execute_tear_down()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return case_result
+
+=C2=A0 =C2=A0 @convert_exception(SuiteExecutionError)
+=C2=A0 =C2=A0 def execute_test_cases(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Execute all test cases in one suite.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 # prepare debugger rerun case environment
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 if self._enable_func:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 for case_obj in self._get_functi= onal_cases():
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 for i in range(SET= TINGS.re_run + 1):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ret = =3D self.execute_test_case(case_obj)
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if r= et is False and SETTINGS.re_run:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 self.sut_node.get_session_output(timeout=3D0.5 * (i + 1))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 time.sleep(i + 1)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 self.logger.info(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 =C2=A0 =C2=A0 " Test case %s failed and re-run %d time"= ;
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 =C2=A0 =C2=A0 % (case_obj.__name__, i + 1)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 else= :
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 break
+
+=C2=A0 =C2=A0 def execute_test_case(self, case_obj):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Execute test case or enter into debug mode. +=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return self._execute_test_case(case_obj)
+
+=C2=A0 =C2=A0 def get_result(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Return suite test result
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return self._suite_result
+
+=C2=A0 =C2=A0 @convert_exception(SuiteCleanupError)
+=C2=A0 =C2=A0 def execute_tear_downall(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 execute suite tear_down_all function
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.tear_down_all()
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.sut_node.kill_cleanup_dpdk_apps()
+
+=C2=A0 =C2=A0 def execute_tear_down(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 execute suite tear_down function
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.tear_down()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 except Exception:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.error("tear_dow= n failed:\n" + traceback.format_exc())
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.logger.warning(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "tear down %s= failed, might iterfere next case's result!"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 % self.running_cas= e
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 @staticmethod
+=C2=A0 =C2=A0 def get_testcases(testsuite_module_path: str) -> list[typ= e["TestCase"]]:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 def is_testcase(object) -> bool:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if issubclass(obje= ct, TestCase) and object !=3D TestCase:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 retu= rn True
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 except TypeError:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return False
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return False
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase_module =3D importlib.im= port_module(testsuite_module_path)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 except ModuleNotFoundError as e:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 raise TestSuiteNotFound(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"Testsuite &= #39;{testsuite_module_path}' not found."
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ) from e
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return [
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 testcase_class
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 for _, testcase_class in inspect= .getmembers(testcase_module, is_testcase)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 ]
--
2.30.2

--00000000000065b68705ed97f2bb--