Skip to content

Commit 9fc86f6

Browse files
committed
test: Added xpath coverage report generation (make test)
Resolves: #581 Signed-off-by: Ejub Sabic <ejub1946@outlook.com>
1 parent f4fd538 commit 9fc86f6

8 files changed

Lines changed: 614 additions & 4 deletions

File tree

.github/workflows/test.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ jobs:
116116
tar xf "$tarfile"
117117
ln -s "${tarfile%.tar.gz}" images
118118
119+
- name: Extract YANG XPaths for coverage
120+
run: |
121+
python3 -m venv $RUNNER_TEMP/pyang-env
122+
$RUNNER_TEMP/pyang-env/bin/pip install pyang weasyprint -q
123+
mkdir -p $TEST_PATH/.log
124+
$RUNNER_TEMP/pyang-env/bin/python3 $TEST_PATH/utils/extract_xpaths.py \
125+
src/confd/yang/confd \
126+
$TEST_PATH/.log/xpaths_all.csv || true
127+
119128
- name: Regression Test ${{ env.TARGET }}
120129
env:
121130
INFIX_RELEASE: ${{ inputs.release }}
@@ -143,3 +152,16 @@ jobs:
143152
with:
144153
name: test-report
145154
path: output/images/test-report.pdf
155+
156+
- name: Generate XPath Coverage Report for ${{ env.TARGET }}
157+
if: always()
158+
run: |
159+
export PATH="$RUNNER_TEMP/pyang-env/bin:$PATH"
160+
make test-dir="$(pwd)/$TEST_PATH" xpath-coverage-report
161+
162+
- name: Upload XPath Coverage Report as Artifact
163+
if: always()
164+
uses: actions/upload-artifact@v7
165+
with:
166+
name: xpath-coverage-report
167+
path: output/images/xpath-coverage-report.pdf

test/infamy/coverage.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""XPath coverage tracker for infamy test runs.
2+
3+
Writes covered xpaths to $NINEPM_LOG_PATH/xpath_coverage.log, one per line.
4+
Call track_xpath(), track_module(), or track_dict() from transport methods.
5+
"""
6+
7+
import os
8+
import threading
9+
10+
_lock = threading.Lock()
11+
_tracked: set[str] = set()
12+
13+
14+
def _log_path() -> str | None:
15+
log_dir = os.environ.get("NINEPM_LOG_PATH")
16+
if not log_dir:
17+
return None
18+
return os.path.join(log_dir, "xpath_coverage.log")
19+
20+
21+
def _flush(xpaths: list[str]) -> None:
22+
path = _log_path()
23+
if not path or not xpaths:
24+
return
25+
try:
26+
with open(path, "a", encoding="utf-8") as f:
27+
for x in xpaths:
28+
f.write(x + "\n")
29+
except OSError:
30+
pass
31+
32+
33+
def _record(xpaths: list[str]) -> None:
34+
new: list[str] = []
35+
with _lock:
36+
for x in xpaths:
37+
if x not in _tracked:
38+
_tracked.add(x)
39+
new.append(x)
40+
_flush(new)
41+
42+
43+
def _normalize(xpath: str) -> str:
44+
"""Strip module prefix from every segment except the root.
45+
46+
pyang and the CSV always use /mod:top/child (no mid-path prefixes).
47+
Tests sometimes pass /mod:top/other-mod:child; we canonicalize here.
48+
"""
49+
if not xpath or ":" not in xpath:
50+
return xpath
51+
segs = xpath.lstrip("/").split("/")
52+
out = []
53+
for i, seg in enumerate(segs):
54+
out.append(seg if i == 0 else seg.split(":", 1)[-1])
55+
return "/" + "/".join(out)
56+
57+
58+
def track_xpath(xpath: str | None) -> None:
59+
"""Record a direct xpath access (get_data, delete_xpath, call_action, ...)."""
60+
if xpath:
61+
_record([_normalize(xpath)])
62+
63+
64+
def track_module(modname: str | None) -> None:
65+
"""Record a module-level access (call_dict with no specific path)."""
66+
if modname:
67+
_record([f"/{modname}:*"])
68+
69+
70+
def track_dict(modname: str | None, data: object) -> None:
71+
"""Record xpaths implied by a config/rpc dict (put_config_dicts, ...)."""
72+
if not modname or not isinstance(data, dict):
73+
return
74+
_record(list(_dict_to_xpaths(modname, data)))
75+
76+
77+
def _dict_to_xpaths(modname: str, data: object, prefix: str = "") -> set[str]:
78+
xpaths: set[str] = set()
79+
80+
if isinstance(data, dict):
81+
for key, value in data.items():
82+
node = key.split(":", 1)[-1]
83+
84+
if not prefix:
85+
xpath = f"/{modname}:{node}"
86+
else:
87+
xpath = f"{prefix}/{node}"
88+
89+
xpaths.add(xpath)
90+
xpaths.update(_dict_to_xpaths(modname, value, xpath))
91+
elif isinstance(data, list):
92+
for item in data:
93+
xpaths.update(_dict_to_xpaths(modname, item, prefix))
94+
95+
return xpaths

test/infamy/netconf.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
import netconf_client.ncclient
1818
from infamy.transport import Transport,infer_put_dict
1919
from netconf_client.error import RpcError
20-
from . import env, netutil
20+
from . import env, netutil, coverage
2121

2222

2323
def netconf_syn(addr):
@@ -249,6 +249,7 @@ def reboot(self):
249249
})
250250

251251
def get(self, xpath, parse=True):
252+
coverage.track_xpath(xpath)
252253
xpath_filter = self._build_xpath_filter(xpath)
253254
response = self.ncc.get(filter=xpath_filter)
254255
return self._parse_response(response, parse)
@@ -263,6 +264,7 @@ def get_dict(self, xpath):
263264
return data.print_dict()
264265

265266
def get_data(self, xpath=None, parse=True):
267+
coverage.track_xpath(xpath)
266268
xpath_filter = self._build_xpath_filter(xpath, get_data_xpath=True)
267269
response = self.ncc.get_data(datastore="ds:operational", filter=xpath_filter)
268270
parsed_data = self._parse_response(response, parse)
@@ -273,6 +275,7 @@ def get_data(self, xpath=None, parse=True):
273275
return parsed_data
274276

275277
def get_config(self, xpath):
278+
coverage.track_xpath(xpath)
276279
xpath_filter = self._build_xpath_filter(xpath)
277280
response = self.ncc.get_config(source="running", filter=xpath_filter)
278281
return self._parse_response(response, True)
@@ -317,6 +320,8 @@ def put_config_dicts(self, models, retries=3):
317320
"""PUT full configuration of all models to running-config"""
318321
config = ""
319322
infer_put_dict(self.name, models)
323+
for mod, data in models.items():
324+
coverage.track_dict(mod, data)
320325

321326
for model in models.keys():
322327
try:
@@ -349,6 +354,7 @@ def patch_config(self, modname, edit, retries=3):
349354
f"Available models can be checked with get_schema_list()") from None
350355
lyd = mod.parse_data_dict(edit, no_state=True, validate=False)
351356
config = lyd.print_mem("xml", with_siblings=True, pretty=False)
357+
coverage.track_dict(modname, edit)
352358
# print(f"Send new XML config: {config}")
353359
return self.put_config(config, retries=retries)
354360

@@ -358,6 +364,7 @@ def call(self, call):
358364

359365
def call_dict(self, modname, call):
360366
"""Call RPC, Python dictionary version"""
367+
coverage.track_dict(modname, call)
361368
try:
362369
mod = self.ly.get_module(modname)
363370
except libyang.util.LibyangError:
@@ -374,6 +381,7 @@ def call_action(self, xpath, input_data=None):
374381
the action's input leaves. Defaults to an empty input for
375382
actions that take no parameters.
376383
"""
384+
coverage.track_xpath(xpath)
377385
action={}
378386
pattern = r"^/(?P<module>[^:]+):(?P<path>[^/]+)"
379387
match = re.search(pattern, xpath)
@@ -416,6 +424,7 @@ def get_schema(self, schema, outdir):
416424
f.write(data.schema)
417425

418426
def delete_xpath(self, xpath):
427+
coverage.track_xpath(xpath)
419428
# Split out the model and the container from xpath'
420429
pattern = r"^/(?P<module>[^:]+):(?P<path>[^/]+)"
421430
match = re.search(pattern, xpath)

test/infamy/restconf.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from urllib3.exceptions import InsecureRequestWarning
1212
from dataclasses import dataclass
1313
from infamy.transport import Transport, infer_put_dict
14-
from . import env
14+
from . import env, coverage
1515

1616
# We know we have a self-signed certificate, silence warning about it
1717
warnings.simplefilter('ignore', InsecureRequestWarning)
@@ -258,6 +258,7 @@ def put_datastore(self, datastore, data):
258258

259259
def get_config_dict(self, xpath):
260260
"""Get all configuration matching @xpath as dictionary"""
261+
coverage.track_xpath(xpath)
261262
# Strip leading slash if present (for compatibility with NETCONF xpath style)
262263
xpath = xpath.lstrip('/')
263264

@@ -295,6 +296,8 @@ def put_config_dicts(self, models, retries=3):
295296
retries: Number of retry attempts on failure (default 3)
296297
"""
297298
infer_put_dict(self.name, models)
299+
for mod, data in models.items():
300+
coverage.track_dict(mod, data)
298301

299302
# Copy running to candidate first (to preserve existing config)
300303
self.copy("running", "candidate")
@@ -343,6 +346,7 @@ def put_config_dicts(self, models, retries=3):
343346
# Copy candidate to running (acts as "commit", triggers sysrepo callbacks)
344347
self.copy("candidate", "running")
345348

349+
346350
def patch_config(self, xpath, edit, retries=3):
347351
"""PATCH configuration directly to running datastore
348352
@@ -356,6 +360,7 @@ def patch_config(self, xpath, edit, retries=3):
356360
edit: Configuration dictionary
357361
retries: Number of retry attempts on failure (default 3)
358362
"""
363+
coverage.track_dict(xpath, edit)
359364
try:
360365
mod = self.lyctx.get_module(xpath)
361366
except libyang.util.LibyangError:
@@ -406,6 +411,7 @@ def patch_config(self, xpath, edit, retries=3):
406411
raise last_error
407412

408413
def call_dict(self, model, call):
414+
coverage.track_dict(model, call)
409415
pass # Need implementation
410416

411417
def call_rpc(self, rpc):
@@ -426,6 +432,7 @@ def get_dict(self, xpath=None, parse=True):
426432

427433
def get_data(self, xpath=None, parse=True):
428434
"""Get operational data"""
435+
coverage.track_xpath(xpath)
429436
uri = xpath_to_uri(xpath) if xpath is not None else None
430437
data = self.get_operational(uri, parse)
431438

@@ -453,6 +460,7 @@ def reboot(self):
453460
self.call_rpc("ietf-system:system-restart")
454461

455462
def call_action(self, xpath, input_data=None):
463+
coverage.track_xpath(xpath)
456464
path = xpath_to_uri(xpath)
457465
url = f"{self.restconf_url}/data{path}"
458466

@@ -484,6 +492,7 @@ def call_action(self, xpath, input_data=None):
484492

485493
def delete_xpath(self, xpath):
486494
"""Delete XPath from running config"""
495+
coverage.track_xpath(xpath)
487496
path = f"/ds/ietf-datastores:running{xpath_to_uri(xpath)}"
488497
url = f"{self.restconf_url}{path}"
489498
response = requests_workaround_delete(url, headers=self.headers,

test/test.mk

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ LOGO ?= $(test-dir)/../doc/logo.png[top=40%, align=right, pdfwidth
1010
UNIT_TESTS ?= $(test-dir)/case/all-repo.yaml $(test-dir)/case/all-unit.yaml
1111
TESTS ?= $(test-dir)/case/all.yaml
1212

13+
yang_extractor := $(test-dir)/utils/extract_xpaths.py
14+
coverage_reporter := $(test-dir)/utils/coverage_report.py
15+
YANG_DIR ?= $(BR2_EXTERNAL_INFIX_PATH)/src/confd/yang/confd
16+
xpaths_all_csv := $(test-dir)/.log/xpaths_all.csv
17+
1318
base := -b $(base-dir)
1419

1520
TEST_MODE ?= qeneth
@@ -30,9 +35,15 @@ endif
3035

3136
test:
3237
$(test-dir)/env -r $(base) $(mode) $(binaries) $(pkg-$(ARCH)) \
33-
sh -c '$(ninepm) -v $(TESTS); rc=$$?; \
38+
sh -c 'test -f $(xpaths_all_csv) || python3 $(yang_extractor) $(YANG_DIR) $(xpaths_all_csv) || true; \
39+
$(ninepm) -v $(TESTS); rc=$$?; \
3440
$(ninepm_report) github $(test-dir)/.log/last/result.json; \
3541
$(ninepm_report) asciidoc $(test-dir)/.log/last/result.json; \
42+
python3 $(coverage_reporter) \
43+
$(xpaths_all_csv) \
44+
$(test-dir)/.log/last/xpath_coverage.log \
45+
$(test-dir)/.log/last/xpath_coverage_report.md \
46+
2>/dev/null || true; \
3647
chmod -R 777 $(test-dir)/.log; \
3748
exit $$rc'
3849

@@ -62,9 +73,38 @@ test-report:
6273
-a pdf-fontsdir=$(spec-dir)/fonts \
6374
-o $(test-report) $(test-dir)/.log/last/report.adoc
6475

76+
xpath_coverage_report_md := $(test-dir)/.log/last/xpath_coverage_report.md
77+
xpath_coverage_report_css := $(test-dir)/utils/coverage_report.css
78+
xpath_coverage_report_pdf := $(BINARIES_DIR)/xpath-coverage-report.pdf
79+
xpath-coverage-report:
80+
@ver="$(subst ",,$(INFIX_NAME)) $(INFIX_VERSION)"; \
81+
gen=$$(sed -n 's/^Generated: //p' $(xpath_coverage_report_md) | head -1 | cut -d' ' -f1); \
82+
{ \
83+
printf '<div class="cover">\n'; \
84+
printf '<img class="cover-logo" src="%s">\n' "$(abspath $(test-dir)/../doc/logo.png)"; \
85+
printf '<h1 class="cover-title">XPath Coverage Report</h1>\n'; \
86+
printf '<p class="cover-version">%s</p>\n' "$$ver"; \
87+
printf '<p class="cover-date">%s</p>\n' "$$gen"; \
88+
printf '</div>\n\n'; \
89+
sed -e 's,🟢,<span class="dot ok"></span>,g' \
90+
-e 's,🟠,<span class="dot warn"></span>,g' \
91+
-e 's,🔴,<span class="dot err"></span>,g' \
92+
-e 's,✅,<span class="mark ok">✓</span>,g' \
93+
-e 's,❌,<span class="mark err">✗</span>,g' \
94+
-e '/^# XPath Coverage Report$$/d' \
95+
-e '/^Generated: /d' \
96+
$(xpath_coverage_report_md); \
97+
} > $(xpath_coverage_report_md).pdf.md
98+
pandoc $(xpath_coverage_report_md).pdf.md \
99+
-f gfm \
100+
--pdf-engine=weasyprint \
101+
-c $(xpath_coverage_report_css) \
102+
-o $(xpath_coverage_report_pdf)
103+
@rm -f $(xpath_coverage_report_md).pdf.md
104+
65105
# Unit tests run with random (-r) hostname and container name to
66106
# prevent race conditions when running in CI environments.
67107
test-unit:
68108
$(test-dir)/env -r $(base) $(ninepm) -v $(UNIT_TESTS)
69109

70-
.PHONY: test test-sh test-unit test-spec
110+
.PHONY: test test-sh test-unit test-spec xpath-coverage-report

0 commit comments

Comments
 (0)