Skip to content

Commit 0b67255

Browse files
pdgendtkartben
authored andcommitted
scripts: twister: Add CTest harness
Introduce a twister harness for CMake's CTest. Signed-off-by: Pieter De Gendt <pieter.degendt@basalte.be>
1 parent 33f257b commit 0b67255

File tree

6 files changed

+158
-2
lines changed

6 files changed

+158
-2
lines changed

scripts/pylib/twister/twisterlib/environment.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,12 @@ def add_parse_arguments(parser = None) -> argparse.ArgumentParser:
275275
will extend the pytest_args from the harness_config in YAML file.
276276
""")
277277

278+
parser.add_argument(
279+
"--ctest-args", action="append",
280+
help="""Pass additional arguments to the ctest subprocess. This parameter
281+
will extend the ctest_args from the harness_config in YAML file.
282+
""")
283+
278284
valgrind_asan_group.add_argument(
279285
"--enable-valgrind", action="store_true",
280286
help="""Run binary through valgrind and check for several memory access

scripts/pylib/twister/twisterlib/harness.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from collections import OrderedDict
1717
from enum import Enum
1818

19+
import junitparser.junitparser as junit
1920
from pytest import ExitCode
2021
from twisterlib.constants import SUPPORTED_SIMS_IN_PYTEST
2122
from twisterlib.environment import PYTEST_PLUGIN_INSTALLED, ZEPHYR_BASE
@@ -955,6 +956,142 @@ def build(self):
955956
logger.debug(f'Copying executable from {original_exe_path} to {new_exe_path}')
956957
shutil.copy(original_exe_path, new_exe_path)
957958

959+
class Ctest(Harness):
960+
def configure(self, instance: TestInstance):
961+
super().configure(instance)
962+
self.running_dir = instance.build_dir
963+
self.report_file = os.path.join(self.running_dir, 'report.xml')
964+
self.ctest_log_file_path = os.path.join(self.running_dir, 'twister_harness.log')
965+
self._output = []
966+
967+
def ctest_run(self, timeout):
968+
assert self.instance is not None
969+
try:
970+
cmd = self.generate_command()
971+
self.run_command(cmd, timeout)
972+
except Exception as err:
973+
logger.error(str(err))
974+
self.status = TwisterStatus.FAIL
975+
self.instance.reason = str(err)
976+
finally:
977+
self.instance.record(self.recording)
978+
self._update_test_status()
979+
980+
def generate_command(self):
981+
config = self.instance.testsuite.harness_config
982+
handler: Handler = self.instance.handler
983+
ctest_args_yaml = config.get('ctest_args', []) if config else []
984+
command = [
985+
'ctest',
986+
'--build-nocmake',
987+
'--test-dir',
988+
self.running_dir,
989+
'--output-junit',
990+
self.report_file,
991+
'--output-log',
992+
self.ctest_log_file_path,
993+
'--output-on-failure',
994+
]
995+
base_timeout = handler.get_test_timeout()
996+
command.extend(['--timeout', str(base_timeout)])
997+
command.extend(ctest_args_yaml)
998+
999+
if handler.options.ctest_args:
1000+
command.extend(handler.options.ctest_args)
1001+
1002+
return command
1003+
1004+
def run_command(self, cmd, timeout):
1005+
with subprocess.Popen(
1006+
cmd,
1007+
stdout=subprocess.PIPE,
1008+
stderr=subprocess.STDOUT,
1009+
) as proc:
1010+
try:
1011+
reader_t = threading.Thread(target=self._output_reader, args=(proc,), daemon=True)
1012+
reader_t.start()
1013+
reader_t.join(timeout)
1014+
if reader_t.is_alive():
1015+
terminate_process(proc)
1016+
logger.warning('Timeout has occurred. Can be extended in testspec file. '
1017+
f'Currently set to {timeout} seconds.')
1018+
self.instance.reason = 'Ctest timeout'
1019+
self.status = TwisterStatus.FAIL
1020+
proc.wait(timeout)
1021+
except subprocess.TimeoutExpired:
1022+
self.status = TwisterStatus.FAIL
1023+
proc.kill()
1024+
1025+
if proc.returncode in (ExitCode.INTERRUPTED, ExitCode.USAGE_ERROR, ExitCode.INTERNAL_ERROR):
1026+
self.status = TwisterStatus.ERROR
1027+
self.instance.reason = f'Ctest error - return code {proc.returncode}'
1028+
with open(self.ctest_log_file_path, 'w') as log_file:
1029+
log_file.write(shlex.join(cmd) + '\n\n')
1030+
log_file.write('\n'.join(self._output))
1031+
1032+
def _output_reader(self, proc):
1033+
self._output = []
1034+
while proc.stdout.readable() and proc.poll() is None:
1035+
line = proc.stdout.readline().decode().strip()
1036+
if not line:
1037+
continue
1038+
self._output.append(line)
1039+
logger.debug(f'CTEST: {line}')
1040+
self.parse_record(line)
1041+
proc.communicate()
1042+
1043+
def _update_test_status(self):
1044+
if self.status == TwisterStatus.NONE:
1045+
self.instance.testcases = []
1046+
try:
1047+
self._parse_report_file(self.report_file)
1048+
except Exception as e:
1049+
logger.error(f'Error when parsing file {self.report_file}: {e}')
1050+
self.status = TwisterStatus.FAIL
1051+
finally:
1052+
if not self.instance.testcases:
1053+
self.instance.init_cases()
1054+
1055+
self.instance.status = self.status if self.status != TwisterStatus.NONE else \
1056+
TwisterStatus.FAIL
1057+
if self.instance.status in [TwisterStatus.ERROR, TwisterStatus.FAIL]:
1058+
self.instance.reason = self.instance.reason or 'Ctest failed'
1059+
self.instance.add_missing_case_status(TwisterStatus.BLOCK, self.instance.reason)
1060+
1061+
def _parse_report_file(self, report):
1062+
suite = junit.JUnitXml.fromfile(report)
1063+
if suite is None:
1064+
self.status = TwisterStatus.SKIP
1065+
self.instance.reason = 'No tests collected'
1066+
return
1067+
1068+
assert isinstance(suite, junit.TestSuite)
1069+
1070+
if suite.failures and suite.failures > 0:
1071+
self.status = TwisterStatus.FAIL
1072+
self.instance.reason = f"{suite.failures}/{suite.tests} ctest scenario(s) failed"
1073+
elif suite.errors and suite.errors > 0:
1074+
self.status = TwisterStatus.ERROR
1075+
self.instance.reason = 'Error during ctest execution'
1076+
elif suite.skipped and suite.skipped > 0:
1077+
self.status = TwisterStatus.SKIP
1078+
else:
1079+
self.status = TwisterStatus.PASS
1080+
self.instance.execution_time = suite.time
1081+
1082+
for case in suite:
1083+
tc = self.instance.add_testcase(f"{self.id}.{case.name}")
1084+
tc.duration = case.time
1085+
if any(isinstance(r, junit.Failure) for r in case.result):
1086+
tc.status = TwisterStatus.FAIL
1087+
tc.output = case.system_out
1088+
elif any(isinstance(r, junit.Error) for r in case.result):
1089+
tc.status = TwisterStatus.ERROR
1090+
tc.output = case.system_out
1091+
elif any(isinstance(r, junit.Skipped) for r in case.result):
1092+
tc.status = TwisterStatus.SKIP
1093+
else:
1094+
tc.status = TwisterStatus.PASS
9581095

9591096
class HarnessImporter:
9601097

scripts/pylib/twister/twisterlib/runner.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
sys.path.insert(0, os.path.join(ZEPHYR_BASE, "scripts/pylib/build_helpers"))
4444
from domains import Domains
4545
from twisterlib.environment import TwisterEnv
46-
from twisterlib.harness import HarnessImporter, Pytest
46+
from twisterlib.harness import Ctest, HarnessImporter, Pytest
4747
from twisterlib.log_helper import log_command
4848
from twisterlib.platform import Platform
4949
from twisterlib.testinstance import TestInstance
@@ -1745,6 +1745,8 @@ def run(self):
17451745
#
17461746
if isinstance(harness, Pytest):
17471747
harness.pytest_run(instance.handler.get_test_timeout())
1748+
elif isinstance(harness, Ctest):
1749+
harness.ctest_run(instance.handler.get_test_timeout())
17481750
else:
17491751
instance.handler.handle(harness)
17501752

scripts/pylib/twister/twisterlib/testinstance.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ def get_case_or_create(self, name):
213213
def testsuite_runnable(testsuite, fixtures):
214214
can_run = False
215215
# console harness allows us to run the test and capture data.
216-
if testsuite.harness in [ 'console', 'ztest', 'pytest', 'test', 'gtest', 'robot']:
216+
if testsuite.harness in ['console', 'ztest', 'pytest', 'test', 'gtest', 'robot', 'ctest']:
217217
can_run = True
218218
# if we have a fixture that is also being supplied on the
219219
# command-line, then we need to run the test, not just build it.
@@ -256,6 +256,8 @@ def setup_handler(self, env: TwisterEnv):
256256
handler.ready = True
257257
else:
258258
handler = Handler(self, "", *common_args)
259+
if self.testsuite.harness == "ctest":
260+
handler.ready = True
259261

260262
self.handler = handler
261263

@@ -291,6 +293,7 @@ def check_runnable(self,
291293

292294
target_ready = bool(self.testsuite.type == "unit" or \
293295
self.platform.type == "native" or \
296+
self.testsuite.harness == "ctest" or \
294297
(simulator and simulator.name in SUPPORTED_SIMS and \
295298
simulator.name not in self.testsuite.simulation_exclude) or \
296299
device_testing)

scripts/requirements-build-test.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,6 @@ mypy
1919

2020
# used for mocking functions in pytest
2121
mock>=4.0.1
22+
23+
# used for JUnit XML parsing in CTest harness
24+
junitparser

scripts/schemas/twister/testsuite-schema.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ schema;scenario-schema:
130130
type: str
131131
enum: ["function", "class", "module", "package", "session"]
132132
required: false
133+
"ctest_args":
134+
type: seq
135+
required: false
136+
sequence:
137+
- type: str
133138
"regex":
134139
type: seq
135140
required: false

0 commit comments

Comments
 (0)