diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 707d5dfc..be21f372 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -143,3 +143,15 @@ jobs: with: name: test-report path: output/images/test-report.pdf + + - name: Generate XPath Coverage Report for ${{ env.TARGET }} + if: always() + run: | + make test-dir="$(pwd)/$TEST_PATH" xpath-coverage-report + + - name: Upload XPath Coverage Report as Artifact + if: always() + uses: actions/upload-artifact@v7 + with: + name: xpath-coverage-report + path: output/images/xpath-coverage-report.pdf diff --git a/test/.env b/test/.env index b804a029..857d9e12 100644 --- a/test/.env +++ b/test/.env @@ -2,7 +2,7 @@ # shellcheck disable=SC2034,SC2154 # Current container image -INFIX_TEST=ghcr.io/kernelkit/infix-test:2.9 +INFIX_TEST=ghcr.io/kernelkit/infix-test:2.10 ixdir=$(readlink -f "$testdir/..") logdir=$(readlink -f "$testdir/.log") diff --git a/test/docker/Dockerfile b/test/docker/Dockerfile index 7678ec10..c1750270 100644 --- a/test/docker/Dockerfile +++ b/test/docker/Dockerfile @@ -11,6 +11,7 @@ RUN apk add --no-cache \ ethtool \ fakeroot \ file \ + font-dejavu \ gcc \ git \ graphviz \ @@ -24,6 +25,7 @@ RUN apk add --no-cache \ nmap \ openssh-client \ openssl \ + pandoc-cli \ python3-dev \ qemu-img \ qemu-system-x86_64 \ @@ -33,7 +35,8 @@ RUN apk add --no-cache \ squashfs-tools \ sshpass \ tcpdump \ - tshark + tshark \ + weasyprint ARG MTOOL_VERSION="3.0" RUN wget https://github.com/troglobit/mtools/releases/download/v3.0/mtools-$MTOOL_VERSION.tar.gz -O /tmp/mtools-$MTOOL_VERSION.tar.gz diff --git a/test/docker/pip-requirements.txt b/test/docker/pip-requirements.txt index 83793011..06bb9eba 100644 --- a/test/docker/pip-requirements.txt +++ b/test/docker/pip-requirements.txt @@ -1,4 +1,5 @@ libyang==2.7.1 +pyang==2.7.1 netconf_client==3.1.1 netifaces==0.11.0 networkx==3.1 diff --git a/test/infamy/coverage.py b/test/infamy/coverage.py new file mode 100644 index 00000000..8561d3f7 --- /dev/null +++ b/test/infamy/coverage.py @@ -0,0 +1,95 @@ +"""XPath coverage tracker for infamy test runs. + +Writes covered xpaths to $NINEPM_LOG_PATH/xpath_coverage.log, one per line. +Call track_xpath(), track_module(), or track_dict() from transport methods. +""" + +import os +import threading + +_lock = threading.Lock() +_tracked: set[str] = set() + + +def _log_path() -> str | None: + log_dir = os.environ.get("NINEPM_LOG_PATH") + if not log_dir: + return None + return os.path.join(log_dir, "xpath_coverage.log") + + +def _flush(xpaths: list[str]) -> None: + path = _log_path() + if not path or not xpaths: + return + try: + with open(path, "a", encoding="utf-8") as f: + for x in xpaths: + f.write(x + "\n") + except OSError: + pass + + +def _record(xpaths: list[str]) -> None: + new: list[str] = [] + with _lock: + for x in xpaths: + if x not in _tracked: + _tracked.add(x) + new.append(x) + _flush(new) + + +def _normalize(xpath: str) -> str: + """Strip module prefix from every segment except the root. + + pyang and the CSV always use /mod:top/child (no mid-path prefixes). + Tests sometimes pass /mod:top/other-mod:child; we canonicalize here. + """ + if not xpath or ":" not in xpath: + return xpath + segs = xpath.lstrip("/").split("/") + out = [] + for i, seg in enumerate(segs): + out.append(seg if i == 0 else seg.split(":", 1)[-1]) + return "/" + "/".join(out) + + +def track_xpath(xpath: str | None) -> None: + """Record a direct xpath access (get_data, delete_xpath, call_action, ...).""" + if xpath: + _record([_normalize(xpath)]) + + +def track_module(modname: str | None) -> None: + """Record a module-level access (call_dict with no specific path).""" + if modname: + _record([f"/{modname}:*"]) + + +def track_dict(modname: str | None, data: object) -> None: + """Record xpaths implied by a config/rpc dict (put_config_dicts, ...).""" + if not modname or not isinstance(data, dict): + return + _record(list(_dict_to_xpaths(modname, data))) + + +def _dict_to_xpaths(modname: str, data: object, prefix: str = "") -> set[str]: + xpaths: set[str] = set() + + if isinstance(data, dict): + for key, value in data.items(): + node = key.split(":", 1)[-1] + + if not prefix: + xpath = f"/{modname}:{node}" + else: + xpath = f"{prefix}/{node}" + + xpaths.add(xpath) + xpaths.update(_dict_to_xpaths(modname, value, xpath)) + elif isinstance(data, list): + for item in data: + xpaths.update(_dict_to_xpaths(modname, item, prefix)) + + return xpaths diff --git a/test/infamy/netconf.py b/test/infamy/netconf.py index c82fc33a..dbcf2282 100644 --- a/test/infamy/netconf.py +++ b/test/infamy/netconf.py @@ -17,7 +17,7 @@ import netconf_client.connect import netconf_client.ncclient from infamy.transport import Transport,infer_put_dict from netconf_client.error import RpcError -from . import env, netutil +from . import env, netutil, coverage def netconf_syn(addr): @@ -249,6 +249,7 @@ class Device(Transport): }) def get(self, xpath, parse=True): + coverage.track_xpath(xpath) xpath_filter = self._build_xpath_filter(xpath) response = self.ncc.get(filter=xpath_filter) return self._parse_response(response, parse) @@ -263,6 +264,7 @@ class Device(Transport): return data.print_dict() def get_data(self, xpath=None, parse=True): + coverage.track_xpath(xpath) xpath_filter = self._build_xpath_filter(xpath, get_data_xpath=True) response = self.ncc.get_data(datastore="ds:operational", filter=xpath_filter) parsed_data = self._parse_response(response, parse) @@ -273,6 +275,7 @@ class Device(Transport): return parsed_data def get_config(self, xpath): + coverage.track_xpath(xpath) xpath_filter = self._build_xpath_filter(xpath) response = self.ncc.get_config(source="running", filter=xpath_filter) return self._parse_response(response, True) @@ -317,6 +320,8 @@ class Device(Transport): """PUT full configuration of all models to running-config""" config = "" infer_put_dict(self.name, models) + for mod, data in models.items(): + coverage.track_dict(mod, data) for model in models.keys(): try: @@ -349,6 +354,7 @@ class Device(Transport): f"Available models can be checked with get_schema_list()") from None lyd = mod.parse_data_dict(edit, no_state=True, validate=False) config = lyd.print_mem("xml", with_siblings=True, pretty=False) + coverage.track_dict(modname, edit) # print(f"Send new XML config: {config}") return self.put_config(config, retries=retries) @@ -358,6 +364,7 @@ class Device(Transport): def call_dict(self, modname, call): """Call RPC, Python dictionary version""" + coverage.track_dict(modname, call) try: mod = self.ly.get_module(modname) except libyang.util.LibyangError: @@ -374,6 +381,7 @@ class Device(Transport): the action's input leaves. Defaults to an empty input for actions that take no parameters. """ + coverage.track_xpath(xpath) action={} pattern = r"^/(?P[^:]+):(?P[^/]+)" match = re.search(pattern, xpath) @@ -416,6 +424,7 @@ class Device(Transport): f.write(data.schema) def delete_xpath(self, xpath): + coverage.track_xpath(xpath) # Split out the model and the container from xpath' pattern = r"^/(?P[^:]+):(?P[^/]+)" match = re.search(pattern, xpath) diff --git a/test/infamy/restconf.py b/test/infamy/restconf.py index ed7ec0f9..3cb0a5be 100644 --- a/test/infamy/restconf.py +++ b/test/infamy/restconf.py @@ -11,7 +11,7 @@ from requests.auth import HTTPBasicAuth from urllib3.exceptions import InsecureRequestWarning from dataclasses import dataclass from infamy.transport import Transport, infer_put_dict -from . import env +from . import env, coverage # We know we have a self-signed certificate, silence warning about it warnings.simplefilter('ignore', InsecureRequestWarning) @@ -258,6 +258,7 @@ class Device(Transport): def get_config_dict(self, xpath): """Get all configuration matching @xpath as dictionary""" + coverage.track_xpath(xpath) # Strip leading slash if present (for compatibility with NETCONF xpath style) xpath = xpath.lstrip('/') @@ -295,6 +296,8 @@ class Device(Transport): retries: Number of retry attempts on failure (default 3) """ infer_put_dict(self.name, models) + for mod, data in models.items(): + coverage.track_dict(mod, data) # Copy running to candidate first (to preserve existing config) self.copy("running", "candidate") @@ -343,6 +346,7 @@ class Device(Transport): # Copy candidate to running (acts as "commit", triggers sysrepo callbacks) self.copy("candidate", "running") + def patch_config(self, xpath, edit, retries=3): """PATCH configuration directly to running datastore @@ -356,6 +360,7 @@ class Device(Transport): edit: Configuration dictionary retries: Number of retry attempts on failure (default 3) """ + coverage.track_dict(xpath, edit) try: mod = self.lyctx.get_module(xpath) except libyang.util.LibyangError: @@ -406,6 +411,7 @@ class Device(Transport): raise last_error def call_dict(self, model, call): + coverage.track_dict(model, call) pass # Need implementation def call_rpc(self, rpc): @@ -426,6 +432,7 @@ class Device(Transport): def get_data(self, xpath=None, parse=True): """Get operational data""" + coverage.track_xpath(xpath) uri = xpath_to_uri(xpath) if xpath is not None else None data = self.get_operational(uri, parse) @@ -453,6 +460,7 @@ class Device(Transport): self.call_rpc("ietf-system:system-restart") def call_action(self, xpath, input_data=None): + coverage.track_xpath(xpath) path = xpath_to_uri(xpath) url = f"{self.restconf_url}/data{path}" @@ -484,6 +492,7 @@ class Device(Transport): def delete_xpath(self, xpath): """Delete XPath from running config""" + coverage.track_xpath(xpath) path = f"/ds/ietf-datastores:running{xpath_to_uri(xpath)}" url = f"{self.restconf_url}{path}" response = requests_workaround_delete(url, headers=self.headers, diff --git a/test/test.mk b/test/test.mk index 9ff8d4c5..df3894bb 100644 --- a/test/test.mk +++ b/test/test.mk @@ -10,6 +10,11 @@ LOGO ?= $(test-dir)/../doc/logo.png[top=40%, align=right, pdfwidth UNIT_TESTS ?= $(test-dir)/case/all-repo.yaml $(test-dir)/case/all-unit.yaml TESTS ?= $(test-dir)/case/all.yaml +yang_extractor := $(test-dir)/utils/extract_xpaths.py +coverage_reporter := $(test-dir)/utils/coverage_report.py +YANG_DIR ?= $(BR2_EXTERNAL_INFIX_PATH)/src/confd/yang/confd +xpaths_all_csv := $(test-dir)/.log/xpaths_all.csv + base := -b $(base-dir) TEST_MODE ?= qeneth @@ -30,9 +35,15 @@ endif test: $(test-dir)/env -r $(base) $(mode) $(binaries) $(pkg-$(ARCH)) \ - sh -c '$(ninepm) -v $(TESTS); rc=$$?; \ + sh -c 'test -f $(xpaths_all_csv) || python3 $(yang_extractor) $(YANG_DIR) $(xpaths_all_csv) || true; \ + $(ninepm) -v $(TESTS); rc=$$?; \ $(ninepm_report) github $(test-dir)/.log/last/result.json; \ $(ninepm_report) asciidoc $(test-dir)/.log/last/result.json; \ + python3 $(coverage_reporter) \ + $(xpaths_all_csv) \ + $(test-dir)/.log/last/xpath_coverage.log \ + $(test-dir)/.log/last/xpath_coverage_report.md \ + 2>/dev/null || true; \ chmod -R 777 $(test-dir)/.log; \ exit $$rc' @@ -62,9 +73,24 @@ test-report: -a pdf-fontsdir=$(spec-dir)/fonts \ -o $(test-report) $(test-dir)/.log/last/report.adoc +xpath_coverage_report_md := $(test-dir)/.log/last/xpath_coverage_report.md +xpath_coverage_report_css := $(test-dir)/utils/coverage_report.css +xpath_coverage_report_pdf := $(BINARIES_DIR)/xpath-coverage-report.pdf +xpath_coverage_renderer := $(test-dir)/utils/render_coverage_pdf.sh +xpath_coverage_logo := $(abspath $(test-dir)/../doc/logo.png) +# Rendered inside the infix-test container, which carries pandoc, weasyprint +# and the fonts; make variables are expanded on the host and passed as args. +xpath-coverage-report: + $(test-dir)/env $(base) $(xpath_coverage_renderer) \ + $(xpath_coverage_report_md) \ + $(xpath_coverage_report_css) \ + $(xpath_coverage_logo) \ + "$(subst ",,$(INFIX_NAME)) $(INFIX_VERSION)" \ + $(xpath_coverage_report_pdf) + # Unit tests run with random (-r) hostname and container name to # prevent race conditions when running in CI environments. test-unit: $(test-dir)/env -r $(base) $(ninepm) -v $(UNIT_TESTS) -.PHONY: test test-sh test-unit test-spec +.PHONY: test test-sh test-unit test-spec xpath-coverage-report diff --git a/test/utils/coverage_report.css b/test/utils/coverage_report.css new file mode 100644 index 00000000..c4b4a53e --- /dev/null +++ b/test/utils/coverage_report.css @@ -0,0 +1,137 @@ +/* + * Stylesheet for the XPath coverage report PDF (pandoc --pdf-engine=weasyprint). + * + * The Markdown report uses emoji (🟢🟠🔴✅❌) for status. WeasyPrint cannot + * embed the bitmap glyphs of "Noto Color Emoji" into a PDF, so those render as + * empty boxes. The Makefile therefore rewrites the emoji into marks + * (● ✓ ✗) which are plain text glyphs present in every font; the colours below + * restore the green/orange/red meaning. + */ + +@page { + size: A4; + margin: 18mm 14mm; + + /* Footer: a full-width rule with the page number on it. The number sits + * in the outside corner (alternated below); the border-top draws the line + * above the footer, like the test specification/report. */ + @bottom-center { + content: counter(page); + width: 100%; + border-top: 0.75pt solid #999999; + padding-top: 6pt; + font-family: "DejaVu Sans", "Noto Sans", sans-serif; + font-size: 8pt; + color: #777777; + } +} + +/* Page number in the outside corner: left on left-hand (even) pages, + * right on right-hand (odd) pages. */ +@page :left { @bottom-center { text-align: left; } } +@page :right { @bottom-center { text-align: right; } } + +/* The cover page carries no footer (no rule, no number). */ +@page cover { + @bottom-center { content: none; } +} + +body { + font-family: "DejaVu Sans", "Noto Sans", sans-serif; + font-size: 9pt; + line-height: 1.4; + color: #1b1b1b; +} + +h1 { font-size: 22pt; margin: 0 0 4pt; } + +/* + * Cover page — logo, title, software version and generation date, on a page of + * its own (page-break-after). Mirrors the asciidoctor-pdf title page used by + * the test specification/report; the logo is doc/logo.png (set by the Makefile). + * + * NOTE: WeasyPrint does not support viewport units (vh/vw), so the vertical + * offset is a fixed length (~40% down an A4 page, like the test report logo). + */ +.cover { + page: cover; + page-break-after: always; + text-align: right; + margin-left: auto; + padding-top: 80mm; + /* Cover is page 0 (number suppressed below), so the first body page is 1. */ + counter-reset: page 0; +} +.cover-logo { + width: 90mm; + margin-bottom: 18pt; +} +.cover-title { + color: gray; + font-size: 26pt; + font-weight: 400; + margin: 0 0 8pt; + border: none; +} +.cover-version { + font-size: 13pt; + font-weight: 400; + margin: 0 0 2pt; +} +.cover-date { + font-size: 11pt; + color: #777777; + margin: 0; +} +h2 { + font-size: 15pt; + margin: 18pt 0 6pt; + border-bottom: 1px solid #dddddd; + padding-bottom: 3pt; +} +h3 { font-size: 11pt; margin: 14pt 0 4pt; } + +code, pre { + font-family: "DejaVu Sans Mono", "Noto Sans Mono", monospace; + font-size: 8pt; +} + +table { + border-collapse: collapse; + width: 100%; + margin: 6pt 0 12pt; +} + +th, td { + border: 1px solid #d0d0d0; + padding: 3pt 6pt; + vertical-align: top; + /* Long XPaths have no spaces; allow them to wrap anywhere. */ + word-break: break-all; + overflow-wrap: anywhere; +} + +thead th { + background: #f2f2f2; + font-weight: 600; + border-bottom: 2px solid #bbbbbb; +} + +tbody tr:nth-child(even) { background: #fafafa; } + +/* Status dots (module summary + section headings). */ +.dot { + display: inline-block; + width: 9pt; + height: 9pt; + border-radius: 50%; + vertical-align: middle; +} +.dot.ok { background: #2e9e44; } /* 🟢 >= 80% */ +.dot.warn { background: #f0883e; } /* 🟠 1-79% */ +.dot.err { background: #e5534b; } /* 🔴 0% */ + +/* Per-row covered marks. */ +.mark { font-weight: 700; } +.mark.ok { color: #2e9e44; } /* ✅ */ +.mark.err { color: #e5534b; } /* ❌ */ diff --git a/test/utils/coverage_report.py b/test/utils/coverage_report.py new file mode 100644 index 00000000..54e315e0 --- /dev/null +++ b/test/utils/coverage_report.py @@ -0,0 +1,191 @@ +#!/usr/bin/env python3 +"""Generate an XPath coverage report after a test run. + +Usage: + coverage_report.py + +xpaths_all.csv - produced by extract_xpaths.py; columns: module,keyword,xpath +xpath_coverage.log - produced by infamy/coverage.py; one xpath per line +output.md - destination for the markdown report +""" + +import csv +import os +import sys +from collections import defaultdict +from datetime import datetime + + +# Thresholds for colour coding +GREEN_THRESHOLD = 80 # % +ORANGE_THRESHOLD = 1 # % (anything >= 1 and < 80 is orange) + +# Modules excluded from the report +SKIP_MODULES: set[str] = { + "infix-if-base", # submodule of infix-interfaces; only a structural choice node +} + + +def load_all_xpaths(csv_path: str) -> list[dict]: + rows = [] + with open(csv_path, newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + if row["module"] not in SKIP_MODULES: + rows.append(row) + return rows + + +def load_tracked_xpaths(log_path: str) -> set[str]: + tracked: set[str] = set() + if not os.path.exists(log_path): + return tracked + with open(log_path, encoding="utf-8") as f: + for line in f: + x = line.strip() + if x: + tracked.add(x) + return tracked + + +def is_covered(yang_xpath: str, tracked: set[str], module: str, + xpath_module: dict[str, str]) -> bool: + """ Return True if yang_xpath was exercised by a test. """ + if f"/{module}:*" in tracked: + return True + if yang_xpath in tracked: + return True + + prefix = yang_xpath + "/" + for t in tracked: + if not prefix.startswith(t + "/"): + continue + # Determine the defining module of the tracked ancestor + t_module = xpath_module.get(t) + if t_module is None: + # Not in the CSV; infer from the root path segment (e.g. /mod:node → mod) + seg = t.lstrip("/").split("/")[0] + t_module = seg.split(":")[0] if ":" in seg else None + if t_module == module: + return True + + return False + + +def build_coverage(all_xpaths: list[dict], tracked: set[str]) -> dict: + """Return per-module coverage data. + + Returns: + {module: {"total": int, "covered": int, "rows": [{"xpath", "keyword", "covered"}]}} + """ + xpath_module: dict[str, str] = {row["xpath"]: row["module"] for row in all_xpaths} + modules: dict[str, dict] = defaultdict(lambda: {"total": 0, "covered": 0, "rows": []}) + + for row in all_xpaths: + mod = row["module"] + xpath = row["xpath"] + kw = row["keyword"] + hit = is_covered(xpath, tracked, mod, xpath_module) + + modules[mod]["total"] += 1 + modules[mod]["covered"] += int(hit) + modules[mod]["rows"].append({"xpath": xpath, "keyword": kw, "covered": hit}) + + # Sort rows within each module by xpath + for mod_data in modules.values(): + mod_data["rows"].sort(key=lambda r: r["xpath"]) + + return dict(sorted(modules.items())) + + +def status_emoji(pct: float) -> str: + if pct >= GREEN_THRESHOLD: + return "🟢" + if pct >= ORANGE_THRESHOLD: + return "🟠" + return "🔴" + + +def pct(covered: int, total: int) -> float: + return 100.0 * covered / total if total else 0.0 + + +def write_markdown(coverage: dict, output_path: str) -> None: + total_all = sum(d["total"] for d in coverage.values()) + covered_all = sum(d["covered"] for d in coverage.values()) + overall_pct = pct(covered_all, total_all) + + lines: list[str] = [] + lines.append("# XPath Coverage Report\n") + lines.append(f"Generated: {datetime.now().strftime('%Y-%m-%d')}\n") + lines.append("") + + # ── Overall summary ────────────────────────────────────────────────────── + lines.append("## Summary\n") + lines.append( + f"{status_emoji(overall_pct)} **Overall: " + f"{covered_all}/{total_all} XPaths covered " + f"({overall_pct:.1f}%)**\n" + ) + lines.append("") + + lines.append("| Status | Module | Covered | Total | % |") + lines.append("|--------|--------|--------:|------:|--:|") + for mod, d in sorted(coverage.items()): + p = pct(d["covered"], d["total"]) + lines.append( + f"| {status_emoji(p)} | {mod} " + f"| {d['covered']} | {d['total']} | {p:.1f}% |" + ) + lines.append("") + + # ── Per-module detail ───────────────────────────────────────────────────── + lines.append("## Details\n") + + for mod, d in coverage.items(): + p = pct(d["covered"], d["total"]) + lines.append( + f"### {status_emoji(p)} {mod} " + f"({d['covered']}/{d['total']} — {p:.1f}%)\n" + ) + lines.append("| XPath | Keyword | Covered |") + lines.append("|-------|---------|:-------:|") + for row in d["rows"]: + tick = "✅" if row["covered"] else "❌" + lines.append(f"| `{row['xpath']}` | {row['keyword']} | {tick} |") + lines.append("") + + with open(output_path, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + print(f"Coverage report written to {output_path}") + + +def main() -> None: + if len(sys.argv) < 4: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + all_csv = sys.argv[1] + cov_log = sys.argv[2] + out_md = sys.argv[3] + + if not os.path.exists(all_csv): + print(f"Error: XPath list not found: {all_csv}", file=sys.stderr) + sys.exit(1) + + all_xpaths = load_all_xpaths(all_csv) + tracked = load_tracked_xpaths(cov_log) + coverage = build_coverage(all_xpaths, tracked) + + write_markdown(coverage, out_md) + + total_all = sum(d["total"] for d in coverage.values()) + covered_all = sum(d["covered"] for d in coverage.values()) + print( + f"Overall: {covered_all}/{total_all} XPaths covered " + f"({pct(covered_all, total_all):.1f}%)" + ) + + +if __name__ == "__main__": + main() diff --git a/test/utils/extract_xpaths.py b/test/utils/extract_xpaths.py new file mode 100644 index 00000000..61daf4c7 --- /dev/null +++ b/test/utils/extract_xpaths.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Extract all XPaths from YANG modules in a directory. + +Outputs a CSV with columns: module, keyword, xpath +XPaths use NETCONF format: /module:top-container/sub/... +""" + +import os +import sys +import csv +from pyang import context, repository + + +def build_xpath(node): + """Build a NETCONF-style xpath for a schema node.""" + parts = [] + module_name = None + + while node is not None: + if hasattr(node, "arg") and node.arg: + parts.append(node.arg) + parent = getattr(node, "parent", None) + if parent is None or parent.keyword == "module": + if parent is not None: + module_name = parent.arg + break + node = parent + + reversed_parts = list(reversed(parts)) + if module_name and reversed_parts: + reversed_parts[0] = f"{module_name}:{reversed_parts[0]}" + + return "/" + "/".join(reversed_parts) + + +def collect_xpaths(stmt, results): + schema_keywords = { + "container", + "list", + "leaf", + "leaf-list", + "choice", + "case", + "rpc", + "action", + "notification", + "anyxml", + "anydata", + } + + # config false (operational state) is inherited; pyang resolves the + # effective status into i_config during validation. Only skip when it is + # explicitly False so rpc/action/notification (i_config is None) are kept. + if stmt.keyword in schema_keywords and getattr(stmt, "i_config", None) is not False: + results.append({ + "module": stmt.i_module.arg, + "keyword": stmt.keyword, + "xpath": build_xpath(stmt), + }) + + for child in getattr(stmt, "i_children", []): + collect_xpaths(child, results) + + +def load_yang_modules(yang_dir): + repo = repository.FileRepository(yang_dir) + ctx = context.Context(repo) + + for root, _, files in os.walk(yang_dir): + for file in sorted(files): + if file.endswith(".yang") and "@" not in file: + filepath = os.path.join(root, file) + with open(filepath, "r", encoding="utf-8") as f: + text = f.read() + module = ctx.add_module(file, text) + if module is None: + print(f"Warning: failed to load {file}", file=sys.stderr) + + ctx.validate() + return ctx + + +def export_xpaths(yang_dir, output_csv): + ctx = load_yang_modules(yang_dir) + + results = [] + for module in ctx.modules.values(): + for child in getattr(module, "i_children", []): + collect_xpaths(child, results) + + out_dir = os.path.dirname(output_csv) + if out_dir: + os.makedirs(out_dir, exist_ok=True) + + with open(output_csv, "w", newline="", encoding="utf-8") as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=["module", "keyword", "xpath"]) + writer.writeheader() + for row in sorted(results, key=lambda x: (x["module"], x["xpath"])): + writer.writerow(row) + + print(f"Extracted {len(results)} XPaths to {output_csv}") + return len(results) + + +if __name__ == "__main__": + if len(sys.argv) < 3: + print(f"Usage: {sys.argv[0]} ") + sys.exit(1) + + export_xpaths(sys.argv[1], sys.argv[2]) diff --git a/test/utils/render_coverage_pdf.sh b/test/utils/render_coverage_pdf.sh new file mode 100755 index 00000000..9ef1ec2b --- /dev/null +++ b/test/utils/render_coverage_pdf.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# Render the XPath coverage markdown report to PDF using pandoc + weasyprint. +# +# Prepends an HTML cover page and replaces the emoji status markers with +# CSS-styled spans, then hands the result to pandoc. Runs inside the +# infix-test container, where pandoc, weasyprint and the fonts live. +# +# Usage: +# render_coverage_pdf.sh +set -e + +md="$1" +css="$2" +logo="$3" +ver="$4" +pdf="$5" + +gen=$(sed -n 's/^Generated: //p' "$md" | head -1 | cut -d' ' -f1) + +{ + printf '
\n' + printf '\n' "$logo" + printf '

XPath Coverage Report

\n' + printf '

%s

\n' "$ver" + printf '

%s

\n' "$gen" + printf '
\n\n' + sed -e 's,🟢,,g' \ + -e 's,🟠,,g' \ + -e 's,🔴,,g' \ + -e 's,✅,,g' \ + -e 's,❌,,g' \ + -e '/^# XPath Coverage Report$/d' \ + -e '/^Generated: /d' \ + "$md" +} > "$md.pdf.md" + +pandoc "$md.pdf.md" \ + -f gfm \ + --pdf-engine=weasyprint \ + -c "$css" \ + -o "$pdf" + +rm -f "$md.pdf.md"