Skip to content

Commit f93f82f

Browse files
golowanowkartben
authored andcommitted
twister: coverage: Data collection and reporting per-test instance
With this change, the coverage data (GCOV dump) is extracted from the test log files after each of the test instance parallel execution, instead of post processing all the logs at the end of the Twister run. The new `--coverage-per-instance` mode extends Twister coverage operations to report coverage statistics on each test instance execution individually in addition to the default reporting mode which aggregates data to one report with all the test instances in the current scope of the Twister run. The split mode allows to identify precisely what amount of code coverage each test suite provides and to analyze its contribution to the overall test plan's coverage. Each test configuration's output directory will have its own coverage report and data files, so the overall disk space and the total execution time increase. Another new `--disable-coverage-aggregation` option allows to execute only the `--coverage-per-instance` mode when the aggregate coverage report for the whole Twister run scope is not needed. Signed-off-by: Dmitrii Golovanov <[email protected]>
1 parent 220f251 commit f93f82f

File tree

8 files changed

+168
-53
lines changed

8 files changed

+168
-53
lines changed

scripts/pylib/twister/twisterlib/coverage.py

Lines changed: 101 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# vim: set syntax=python ts=4 :
22
#
3-
# Copyright (c) 2018-2022 Intel Corporation
3+
# Copyright (c) 2018-2025 Intel Corporation
44
# SPDX-License-Identifier: Apache-2.0
55

66
import contextlib
@@ -31,6 +31,8 @@ def __init__(self):
3131
self.gcov_tool = None
3232
self.base_dir = None
3333
self.output_formats = None
34+
self.coverage_capture = True
35+
self.coverage_report = True
3436

3537
@staticmethod
3638
def factory(tool, jobs=None):
@@ -109,7 +111,7 @@ def merge_hexdumps(self, hexdumps):
109111

110112
def create_gcda_files(self, extracted_coverage_info):
111113
gcda_created = True
112-
logger.debug("Generating gcda files")
114+
logger.debug(f"Generating {len(extracted_coverage_info)} gcda files")
113115
for filename, hexdumps in extracted_coverage_info.items():
114116
# if kobject_hash is given for coverage gcovr fails
115117
# hence skipping it problem only in gcovr v4.1
@@ -132,7 +134,7 @@ def create_gcda_files(self, extracted_coverage_info):
132134
gcda_created = False
133135
return gcda_created
134136

135-
def generate(self, outdir):
137+
def capture_data(self, outdir):
136138
coverage_completed = True
137139
for filename in glob.glob(f"{outdir}/**/handler.log", recursive=True):
138140
gcov_data = self.__class__.retrieve_gcov_data(filename)
@@ -148,9 +150,15 @@ def generate(self, outdir):
148150
else:
149151
logger.error(f"Gcov data capture incomplete: {filename}")
150152
coverage_completed = False
153+
return coverage_completed
151154

155+
def generate(self, outdir):
156+
coverage_completed = self.capture_data(outdir) if self.coverage_capture else True
157+
if not coverage_completed or not self.coverage_report:
158+
return coverage_completed, {}
159+
reports = {}
152160
with open(os.path.join(outdir, "coverage.log"), "a") as coveragelog:
153-
ret = self._generate(outdir, coveragelog)
161+
ret, reports = self._generate(outdir, coveragelog)
154162
if ret == 0:
155163
report_log = {
156164
"html": "HTML report generated: {}".format(
@@ -180,7 +188,7 @@ def generate(self, outdir):
180188
else:
181189
coverage_completed = False
182190
logger.debug(f"All coverage data processed: {coverage_completed}")
183-
return coverage_completed
191+
return coverage_completed, reports
184192

185193

186194
class Lcov(CoverageTool):
@@ -274,6 +282,7 @@ def _generate(self, outdir, coveragelog):
274282
"--output-file", ztestfile]
275283
self.run_lcov(cmd, coveragelog)
276284

285+
files = []
277286
if os.path.exists(ztestfile) and os.path.getsize(ztestfile) > 0:
278287
cmd = ["--remove", ztestfile,
279288
os.path.join(self.base_dir, "tests/ztest/test/*"),
@@ -289,12 +298,15 @@ def _generate(self, outdir, coveragelog):
289298
self.run_lcov(cmd, coveragelog)
290299

291300
if 'html' not in self.output_formats.split(','):
292-
return 0
301+
return 0, {}
293302

294303
cmd = ["genhtml", "--legend", "--branch-coverage",
295304
"--prefix", self.base_dir,
296305
"-output-directory", os.path.join(outdir, "coverage")] + files
297-
return self.run_command(cmd, coveragelog)
306+
ret = self.run_command(cmd, coveragelog)
307+
308+
# TODO: Add LCOV summary coverage report.
309+
return ret, { 'report': coveragefile, 'ztest': ztestfile, 'summary': None }
298310

299311

300312
class Gcovr(CoverageTool):
@@ -344,8 +356,9 @@ def _flatten_list(list):
344356
return [a for b in list for a in b]
345357

346358
def _generate(self, outdir, coveragelog):
347-
coveragefile = os.path.join(outdir, "coverage.json")
348-
ztestfile = os.path.join(outdir, "ztest.json")
359+
coverage_file = os.path.join(outdir, "coverage.json")
360+
coverage_summary = os.path.join(outdir, "coverage_summary.json")
361+
ztest_file = os.path.join(outdir, "ztest.json")
349362

350363
excludes = Gcovr._interleave_list("-e", self.ignores)
351364
if len(self.ignore_branch_patterns) > 0:
@@ -358,24 +371,37 @@ def _generate(self, outdir, coveragelog):
358371
mode_options = ["--merge-mode-functions=separate"]
359372

360373
# We want to remove tests/* and tests/ztest/test/* but save tests/ztest
361-
cmd = ["gcovr", "-r", self.base_dir,
374+
cmd = ["gcovr", "-v", "-r", self.base_dir,
362375
"--gcov-ignore-parse-errors=negative_hits.warn_once_per_file",
363376
"--gcov-executable", self.gcov_tool,
364377
"-e", "tests/*"]
365-
cmd += excludes + mode_options + ["--json", "-o", coveragefile, outdir]
378+
cmd += excludes + mode_options + ["--json", "-o", coverage_file, outdir]
366379
cmd_str = " ".join(cmd)
367-
logger.debug(f"Running {cmd_str}...")
368-
subprocess.call(cmd, stdout=coveragelog)
369-
370-
subprocess.call(["gcovr", "-r", self.base_dir, "--gcov-executable",
371-
self.gcov_tool, "-f", "tests/ztest", "-e",
372-
"tests/ztest/test/*", "--json", "-o", ztestfile,
373-
outdir] + mode_options, stdout=coveragelog)
374-
375-
if os.path.exists(ztestfile) and os.path.getsize(ztestfile) > 0:
376-
files = [coveragefile, ztestfile]
380+
logger.debug(f"Running: {cmd_str}")
381+
coveragelog.write(f"Running: {cmd_str}\n")
382+
coveragelog.flush()
383+
ret = subprocess.call(cmd, stdout=coveragelog, stderr=coveragelog)
384+
if ret:
385+
logger.error(f"GCOVR failed with {ret}")
386+
return ret, {}
387+
388+
cmd = ["gcovr", "-v", "-r", self.base_dir] + mode_options
389+
cmd += ["--gcov-executable", self.gcov_tool,
390+
"-f", "tests/ztest", "-e", "tests/ztest/test/*",
391+
"--json", "-o", ztest_file, outdir]
392+
cmd_str = " ".join(cmd)
393+
logger.debug(f"Running: {cmd_str}")
394+
coveragelog.write(f"Running: {cmd_str}\n")
395+
coveragelog.flush()
396+
ret = subprocess.call(cmd, stdout=coveragelog, stderr=coveragelog)
397+
if ret:
398+
logger.error(f"GCOVR ztest stage failed with {ret}")
399+
return ret, {}
400+
401+
if os.path.exists(ztest_file) and os.path.getsize(ztest_file) > 0:
402+
files = [coverage_file, ztest_file]
377403
else:
378-
files = [coveragefile]
404+
files = [coverage_file]
379405

380406
subdir = os.path.join(outdir, "coverage")
381407
os.makedirs(subdir, exist_ok=True)
@@ -396,21 +422,21 @@ def _generate(self, outdir, coveragelog):
396422
[report_options[r] for r in self.output_formats.split(',')]
397423
)
398424

399-
return subprocess.call(
400-
["gcovr", "-r", self.base_dir] \
401-
+ mode_options + gcovr_options + tracefiles, stdout=coveragelog
402-
)
425+
cmd = ["gcovr", "-v", "-r", self.base_dir] + mode_options + gcovr_options + tracefiles
426+
cmd += ["--json-summary-pretty", "--json-summary", coverage_summary]
427+
cmd_str = " ".join(cmd)
428+
logger.debug(f"Running: {cmd_str}")
429+
coveragelog.write(f"Running: {cmd_str}\n")
430+
coveragelog.flush()
431+
ret = subprocess.call(cmd, stdout=coveragelog, stderr=coveragelog)
432+
if ret:
433+
logger.error(f"GCOVR merge report stage failed with {ret}")
403434

435+
return ret, { 'report': coverage_file, 'ztest': ztest_file, 'summary': coverage_summary }
404436

405437

406-
def run_coverage(testplan, options):
407-
use_system_gcov = False
438+
def choose_gcov_tool(options, is_system_gcov):
408439
gcov_tool = None
409-
410-
for plat in options.coverage_platform:
411-
_plat = testplan.get_platform(plat)
412-
if _plat and (_plat.type in {"native", "unit"}):
413-
use_system_gcov = True
414440
if not options.gcov_tool:
415441
zephyr_sdk_gcov_tool = os.path.join(
416442
os.environ.get("ZEPHYR_SDK_INSTALL_DIR", default=""),
@@ -427,7 +453,7 @@ def run_coverage(testplan, options):
427453
except OSError:
428454
shutil.copy(llvm_cov, gcov_lnk)
429455
gcov_tool = gcov_lnk
430-
elif use_system_gcov:
456+
elif is_system_gcov:
431457
gcov_tool = "gcov"
432458
elif os.path.exists(zephyr_sdk_gcov_tool):
433459
gcov_tool = zephyr_sdk_gcov_tool
@@ -439,10 +465,19 @@ def run_coverage(testplan, options):
439465
else:
440466
gcov_tool = str(options.gcov_tool)
441467

442-
logger.info("Generating coverage files...")
443-
logger.info(f"Using gcov tool: {gcov_tool}")
468+
return gcov_tool
469+
470+
471+
def run_coverage_tool(options, outdir, is_system_gcov, coverage_capture, coverage_report):
444472
coverage_tool = CoverageTool.factory(options.coverage_tool, jobs=options.jobs)
445-
coverage_tool.gcov_tool = gcov_tool
473+
if not coverage_tool:
474+
return False, {}
475+
476+
coverage_tool.gcov_tool = str(choose_gcov_tool(options, is_system_gcov))
477+
logger.debug(f"Using gcov tool: {coverage_tool.gcov_tool}")
478+
479+
coverage_tool.coverage_capture = coverage_capture
480+
coverage_tool.coverage_report = coverage_report
446481
coverage_tool.base_dir = os.path.abspath(options.coverage_basedir)
447482
# Apply output format default
448483
if options.coverage_formats is not None:
@@ -456,5 +491,32 @@ def run_coverage(testplan, options):
456491
# Ignore branch coverage on __ASSERT* macros
457492
# Covering the failing case is not desirable as it will immediately terminate the test.
458493
coverage_tool.add_ignore_branch_pattern(r"^\s*__ASSERT(?:_EVAL|_NO_MSG|_POST_ACTION)?\(.*")
459-
coverage_completed = coverage_tool.generate(options.outdir)
460-
return coverage_completed
494+
return coverage_tool.generate(outdir)
495+
496+
497+
def has_system_gcov(platform):
498+
return platform and (platform.type in {"native", "unit"})
499+
500+
501+
def run_coverage(options, testplan):
502+
""" Summary code coverage over the full test plan's scope.
503+
"""
504+
is_system_gcov = False
505+
506+
for plat in options.coverage_platform:
507+
if has_system_gcov(testplan.get_platform(plat)):
508+
is_system_gcov = True
509+
break
510+
511+
return run_coverage_tool(options, options.outdir, is_system_gcov,
512+
coverage_capture=False,
513+
coverage_report=True)
514+
515+
516+
def run_coverage_instance(options, instance):
517+
""" Per-instance code coverage called by ProjectBuilder ('coverage' operation).
518+
"""
519+
is_system_gcov = has_system_gcov(instance.platform)
520+
return run_coverage_tool(options, instance.build_dir, is_system_gcov,
521+
coverage_capture=True,
522+
coverage_report=options.coverage_per_instance)

scripts/pylib/twister/twisterlib/environment.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22
# vim: set syntax=python ts=4 :
33
#
4-
# Copyright (c) 2018-2024 Intel Corporation
4+
# Copyright (c) 2018-2025 Intel Corporation
55
# Copyright 2022 NXP
66
# Copyright (c) 2024 Arm Limited (or its affiliates). All rights reserved.
77
#
@@ -380,7 +380,7 @@ def add_parse_arguments(parser = None) -> argparse.ArgumentParser:
380380
"Default to what was selected with --platform.")
381381

382382
coverage_group.add_argument("--coverage-tool", choices=['lcov', 'gcovr'], default='gcovr',
383-
help="Tool to use to generate coverage report (%(default)s - default).")
383+
help="Tool to use to generate coverage reports (%(default)s - default).")
384384

385385
coverage_group.add_argument("--coverage-formats", action="store", default=None,
386386
help="Output formats to use for generated coverage reports " +
@@ -390,6 +390,19 @@ def add_parse_arguments(parser = None) -> argparse.ArgumentParser:
390390
" Valid options for 'lcov' tool are: " +
391391
','.join(supported_coverage_formats['lcov']) + " (html,lcov - default).")
392392

393+
coverage_group.add_argument("--coverage-per-instance", action="store_true", default=False,
394+
help="""Compose individual coverage reports for each test suite
395+
when coverage reporting is enabled; it might run in addition to
396+
the default aggregation mode which produces one coverage report for
397+
all executed test suites. Default: %(default)s""")
398+
399+
coverage_group.add_argument("--disable-coverage-aggregation",
400+
action="store_true", default=False,
401+
help="""Don't aggregate coverage report statistics for all the test suites
402+
selected to run with enabled coverage. Requires another reporting mode to be
403+
active (`--coverage-split`) to have at least one of these reporting modes.
404+
Default: %(default)s""")
405+
393406
parser.add_argument(
394407
"--test-config",
395408
action="store",
@@ -908,6 +921,21 @@ def parse_arguments(
908921
if options.enable_coverage and not options.coverage_platform:
909922
options.coverage_platform = options.platform
910923

924+
if (
925+
(not options.coverage)
926+
and (options.disable_coverage_aggregation or options.coverage_per_instance)
927+
):
928+
logger.error("Enable coverage reporting to set its aggregation mode.")
929+
sys.exit(1)
930+
931+
if (
932+
options.coverage
933+
and options.disable_coverage_aggregation and (not options.coverage_per_instance)
934+
):
935+
logger.error("At least one coverage reporting mode should be enabled: "
936+
"either aggregation, or per-instance, or both.")
937+
sys.exit(1)
938+
911939
if options.coverage_formats:
912940
for coverage_format in options.coverage_formats.split(','):
913941
if coverage_format not in supported_coverage_formats[options.coverage_tool]:

scripts/pylib/twister/twisterlib/reports.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def __init__(self, plan, env) -> None:
5858
self.outdir = os.path.abspath(env.options.outdir)
5959
self.instance_fail_count = plan.instance_fail_count
6060
self.footprint = None
61+
self.coverage_status = None
6162

6263

6364
@staticmethod

scripts/pylib/twister/twisterlib/runner.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# vim: set syntax=python ts=4 :
22
#
3-
# Copyright (c) 2018-2024 Intel Corporation
3+
# Copyright (c) 2018-2025 Intel Corporation
44
# Copyright 2022 NXP
55
# SPDX-License-Identifier: Apache-2.0
66

@@ -42,6 +42,7 @@
4242

4343
sys.path.insert(0, os.path.join(ZEPHYR_BASE, "scripts/pylib/build_helpers"))
4444
from domains import Domains
45+
from twisterlib.coverage import run_coverage_instance
4546
from twisterlib.environment import TwisterEnv
4647
from twisterlib.harness import Ctest, HarnessImporter, Pytest
4748
from twisterlib.log_helper import log_command
@@ -1130,7 +1131,7 @@ def process(self, pipeline, done, message, lock, results):
11301131
self.instance.handler.thread = None
11311132
self.instance.handler.duts = None
11321133

1133-
next_op = 'report'
1134+
next_op = "coverage" if self.options.coverage else "report"
11341135
additionals = {
11351136
"status": self.instance.status,
11361137
"reason": self.instance.reason
@@ -1146,6 +1147,28 @@ def process(self, pipeline, done, message, lock, results):
11461147
finally:
11471148
self._add_to_pipeline(pipeline, next_op, additionals)
11481149

1150+
# Run per-instance code coverage
1151+
elif op == "coverage":
1152+
try:
1153+
logger.debug(f"Run coverage for '{self.instance.name}'")
1154+
self.instance.coverage_status, self.instance.coverage = \
1155+
run_coverage_instance(self.options, self.instance)
1156+
next_op = 'report'
1157+
additionals = {
1158+
"status": self.instance.status,
1159+
"reason": self.instance.reason
1160+
}
1161+
except StatusAttributeError as sae:
1162+
logger.error(str(sae))
1163+
self.instance.status = TwisterStatus.ERROR
1164+
reason = f"Incorrect status assignment on {op}"
1165+
self.instance.reason = reason
1166+
self.instance.add_missing_case_status(TwisterStatus.BLOCK, reason)
1167+
next_op = 'report'
1168+
additionals = {}
1169+
finally:
1170+
self._add_to_pipeline(pipeline, next_op, additionals)
1171+
11491172
# Report results and output progress to screen
11501173
elif op == "report":
11511174
try:

scripts/pylib/twister/twisterlib/testinstance.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# vim: set syntax=python ts=4 :
22
#
3-
# Copyright (c) 2018-2024 Intel Corporation
3+
# Copyright (c) 2018-2025 Intel Corporation
44
# Copyright 2022 NXP
55
# Copyright (c) 2024 Arm Limited (or its affiliates). All rights reserved.
66
#
@@ -59,6 +59,8 @@ def __init__(self, testsuite, platform, toolchain, outdir):
5959
self.metrics = dict()
6060
self.handler = None
6161
self.recording = None
62+
self.coverage = None
63+
self.coverage_status = None
6264
self.outdir = outdir
6365
self.execution_time = 0
6466
self.build_time = 0

0 commit comments

Comments
 (0)