Source code for test_module

# Copyright (c) 2026 inovex GmbH
#
# SPDX-License-Identifier: Apache-2.0

"""Sphinx extension: testmodule and testreport directives (Route B — sphinx-needs)."""
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict
from pathlib import Path

from docutils import nodes
from docutils.parsers.rst import Directive, directives
from docutils.statemachine import ViewList
from doxygen_parser import detail_rst_lines, load_group_index, parse_memberdef
from input_tracking import (  # noqa: F401  (the other hooks are re-exported for tests)
    _merge_inputs,
    _note_input,
    _outdated_by_input_change,
    _purge_inputs,
)
from needs_fields import depends_field, field_type
from rst_builders import (
    RESULT_FIELD_ROLES,
    SCENARIO_YAML_NAMES,
    _need_name,
    build_need_rst,
    build_procedure_need_rst,
    build_result_rst,
    build_scenario_table,
    find_scenario_yaml,
)
from sphinx.util import logging
from twister_reader import (
    depends_met,
    find_build_config,
    find_handler_log,
    fold_parameterized_results,
    load_spec_lookup,
    load_twister_meta,
    parse_twister_results,
    read_kconfig,
    scenario_selected,
    skip_class,
    testcase_statuses,
    testsuite_paths,
)

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Shared utilities
# ---------------------------------------------------------------------------

def _maybe_dump_rst(app, docname: str, directive: str, arg: str, rst_text: str) -> None:
    dump_dir = getattr(app.config, "dump_generated_rst", "")
    if not dump_dir:
        return
    out = Path(dump_dir)
    out.mkdir(parents=True, exist_ok=True)
    doc_slug = docname.replace("/", "__")
    arg_slug = arg.replace("/", "_").replace(".", "_")
    out_file = out / f"{doc_slug}__{directive}__{arg_slug}.rst"
    out_file.write_text(rst_text, encoding="utf-8")
    logger.debug(f"{directive}: dumped generated RST → {out_file}")


def _render_rst(rst_lines, state, content_offset, match_titles=False):
    """Parse a list of RST lines into docutils nodes via nested_parse."""
    vl = ViewList(rst_lines, source="<generated>")
    container = nodes.container()
    state.nested_parse(vl, content_offset, container, match_titles=match_titles)
    return container.children


def _display_name(path_str: str, fallback: str = "not configured") -> str:
    """The bare filename of `path_str`, for a RENDERED (published) node.

    A soft-fail node's own `logger.warning(...)` keeps the full absolute path
    unchanged — that is a build-log detail, meant for whoever is debugging the
    build on this machine. The PARAGRAPH NODE this helper feeds is a published
    artifact, though, and a report that has legitimately not been produced yet
    (design note §7 — docs and test execution are separate pipeline stages) is
    a normal state, not a build-host detail to leak: nothing about where this
    repo happens to be checked out or built belongs on a page a reader may
    view long after the build tree is gone. This is the same D9 shape
    `test_04_paths.py::test_no_host_paths_in_the_published_documents` exists
    to catch generally; it just took until step 28b's report directives for a
    soft-fail message to be the one carrying it.

    `path_str` may be empty (an unconfigured config value, not merely a path
    that does not exist) — `fallback` covers that case rather than rendering
    an empty pair of quotes.
    """
    return Path(path_str).name if path_str else fallback


def _need_names_from_config(app):
    """Merge testmodule_need_types/testmodule_need_links into the single
    role->name dict the rst_builders emitters expect as `need_names`.

    Uses getattr rather than direct attribute access: a handful of the
    soft-fail unit tests drive the directives against lightweight
    SimpleNamespace stand-ins for `app.config` that only set the keys their
    own scenario needs, and this helper is now called earlier in
    TestReportDirective.run() (before load_spec_lookup) than it used to be —
    a real Sphinx `app.config` always has both values via `add_config_value`,
    so this only changes behaviour for those stand-ins, not for a real build.
    """
    return {**getattr(app.config, "testmodule_need_types", {}),
            **getattr(app.config, "testmodule_need_links", {}),
            **getattr(app.config, "testreport_need_fields", {})}


# ---------------------------------------------------------------------------
# testmodule helpers
# ---------------------------------------------------------------------------

def _check_no_ztest_members(proc_cdef: ET.Element) -> None:
    """Warn if a procedure group contains ZTEST-annotated functions."""
    proc_name = proc_cdef.findtext("compoundname", "")
    for md in proc_cdef.findall("sectiondef/memberdef[@kind='function']"):
        fn_name = md.findtext("name", "")
        dd = md.find("detaileddescription")
        if dd is not None:
            for x in dd.findall("para/xrefsect"):
                if "testids" in x.get("id", ""):
                    logger.warning(
                        f"testmodule: procedure group '{proc_name}' contains "
                        f"ZTEST-annotated function '{fn_name}' — check @ingroup annotations"
                    )


def _classify_inner_groups(module_cdef: ET.Element, xml_dir: Path):
    """Split inner groups into (suite_refids, proc_refids) by compoundname suffix."""
    suite_refids, proc_refids = [], []
    for ig in module_cdef.findall("innergroup"):
        refid = ig.get("refid")
        ig_xml = xml_dir / f"{refid}.xml"
        if not ig_xml.exists():
            logger.warning(f"testmodule: inner group XML not found: {ig_xml}")
            continue
        ig_cdef = ET.parse(ig_xml).getroot().find("compounddef")
        if ig_cdef.findtext("compoundname", "").endswith("_procedures"):
            proc_refids.append(refid)
        else:
            suite_refids.append(refid)
    return suite_refids, proc_refids


def _tag_dirs(tag_urls, page_prefix):
    """``testmodule_tag_urls`` as seen from the current page.

    A peer's HTML directory is relative to this document's root, so it gets
    the page's ``../`` prefix, as ``api_doxygen_url`` does; an absolute URL
    (a ``doxygen-external`` peer) is used as it is.
    """
    return {
        tag: url if "://" in url or url.startswith("/") else page_prefix + url
        for tag, url in (tag_urls or {}).items()
    }


[docs] def suite_name_from_group(group_name, qualifier=""): """The ztest suite name a suite group's compoundname stands for. With a `testmodule_suite_qualifier`, the part after its LAST occurrence (``kernel_workq_user_work_module__workqueue_api`` -> ``workqueue_api`` for ``"__"``), so two test modules declaring the same ZTEST_SUITE can give it distinct Doxygen groups. Without a qualifier, without an occurrence of it, or with nothing after it, the whole name. """ suite = group_name.rsplit(qualifier, 1)[-1] if qualifier else group_name return suite or group_name
def _build_suite_rst( suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=None, depends_field=None, suite_qualifier="", tag_dirs=None, ): """Build RST lines for one test suite group (section heading + test_case needs). ``depends_field(conditions, subject)`` decides whether a need gets the ``depends_on`` field (`needs_fields.depends_field`); without it, none does. ``suite_qualifier`` (`testmodule_suite_qualifier`): the need's ``suite`` is the group name after it (`suite_name_from_group`); the fallback id keeps the whole group name, which is unique where the suite name need not be. ``tag_dirs``: where a tag-file reference links, by tag file (`doxygen_parser.RefLinks.tags`); a reference none names goes to ``api_html_dir``. """ suite_xml = xml_dir / f"{suite_refid}.xml" if not suite_xml.exists(): logger.warning(f"testmodule: suite XML not found: {suite_xml}") return [] suite_cdef = ET.parse(suite_xml).getroot().find("compounddef") group_name = suite_cdef.findtext("compoundname", suite_refid) suite_name = suite_name_from_group(group_name, suite_qualifier) compound_id = suite_cdef.get("id", suite_refid) suite_title = suite_cdef.findtext("title", suite_name) lines = [suite_title, "-" * len(suite_title), ""] group_prose = detail_rst_lines(suite_cdef.find("detaileddescription")) if group_prose: lines.extend(group_prose) lines.append("") for memberdef in suite_cdef.findall(".//memberdef[@kind='function']"): info = parse_memberdef( memberdef, compound_id, testspec_html_dir, api_html_dir, tag_dirs ) if not info["name"]: continue with_depends = bool(depends_field) and depends_field( info["depends_on"], f"testmodule: {suite_name}/{info['name']}" ) lines.extend( build_need_rst( info, suite_name, module_path, suite_title, need_names=need_names, depends_field=with_depends, id_scope=group_name, ).splitlines() ) lines.append("") return lines def _build_proc_group_rst( proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=None, tag_dirs=None ): """Build RST lines for one procedure group (section heading + test_procedure needs).""" proc_xml = xml_dir / f"{proc_refid}.xml" proc_cdef = ET.parse(proc_xml).getroot().find("compounddef") proc_compound_id = proc_cdef.get("id", proc_refid) proc_group_name = proc_cdef.findtext("compoundname", proc_refid) proc_heading = proc_cdef.findtext("title", "Shared Test Procedures") _check_no_ztest_members(proc_cdef) lines = [proc_heading, "-" * len(proc_heading), ""] group_prose = detail_rst_lines(proc_cdef.find("detaileddescription")) if group_prose: lines.extend(group_prose) lines.append("") for md in proc_cdef.findall(".//memberdef[@kind='function']"): lines.extend( build_procedure_need_rst( md, proc_compound_id, proc_group_name, testspec_html_dir, api_html_dir, need_names=need_names, tag_dirs=tag_dirs, ).splitlines() ) lines.append("") return lines # --------------------------------------------------------------------------- # testreport helpers # --------------------------------------------------------------------------- def _group_results(results): """Group twister results by (suite, function); return (suite_order, func_order, grouped).""" suite_order, func_order, grouped, seen = [], {}, {}, set() for r in results: s, fn = r["suite"], r["function"] if s not in func_order: suite_order.append(s) func_order[s] = [] if (s, fn) not in seen: func_order[s].append(fn) seen.add((s, fn)) grouped.setdefault((s, fn), []).append(r) for key in grouped: grouped[key].sort(key=lambda r: (r["platform"], r["scenario"])) return suite_order, func_order, grouped def _spec_info(spec_lookup, suite, fn): """The spec test case for a result's (suite, fn), or None — warns when none or several.""" hits = spec_lookup.candidates(suite, fn) if len(hits) == 1: return hits[0] if not hits: logger.warning(f"testreport: '{suite}.{fn}' not in spec needs.json — skipped") else: ids = ", ".join(sorted(h["id"] for h in hits)) logger.warning( f"testreport: '{suite}.{fn}' is ambiguous in spec needs.json ({ids}) — skipped" ) return None def _assess_results(results, spec_lookup, tw_meta, run_dir, note_input=None): """Set ``depends_met`` on every result and ``skip_class`` on every skipped one. ``depends_met`` says whether the build that produced a result met its test case's ``depends_on`` (`twister_reader.depends_met`), read from that build's ``.config`` under ``run_dir`` (`find_build_config`; the testsuite's toolchain and path come from ``tw_meta``, the run's twister.json). ``note_input(path)`` is called for each ``.config`` read. Returns ``{(case id, condition)}`` for the conditions that could not be evaluated, for the caller to warn about once each. """ suites = { (ts.get("platform", ""), ts.get("name", "")): ts for ts in (tw_meta or {}).get("testsuites", []) } configs, unparseable = {}, set() for r in results: info = spec_lookup.find(r["suite"], r["function"]) conditions = (info or {}).get("depends_on") or [] symbols = None if conditions and (ts := suites.get((r["platform"], r["scenario"]))): path = find_build_config( run_dir, r["platform"], ts.get("toolchain", ""), ts.get("path", ""), r["scenario"], ) if path is not None: if path not in configs: if note_input: note_input(path) configs[path] = read_kconfig(path) symbols = configs[path] met, bad = depends_met(conditions, symbols) unparseable.update((info["id"], c) for c in bad) r["depends_met"] = met if (cls := skip_class(r, met)) is not None: r["skip_class"] = cls return unparseable def _declared_result_fields(env, need_names): """The result-field roles whose consumer-named field sphinx-needs has declared.""" return { role for role in RESULT_FIELD_ROLES if field_type(env, _need_name(need_names, role)) is not None } def _build_results_rst( suite_order, func_order, grouped, spec_lookup, need_names=None, fields=(), ): """Build RST lines for all test_result needs, grouped into one section per suite. ``fields``: the result-field roles to set (`rst_builders.build_result_rst`). """ lines = [] for suite in suite_order: suite_title = next( ( info["suite_title"] for fn in func_order[suite] if (info := spec_lookup.find(suite, fn)) and info.get("suite_title") ), None, ) heading = suite_title or suite.replace("_", " ").title() lines += [heading, "-" * len(heading), ""] for fn in func_order[suite]: info = _spec_info(spec_lookup, suite, fn) if info is None: continue for r in grouped[(suite, fn)]: lines += build_result_rst( r, info["id"], info["test_module"], info.get("req_ids"), need_names=need_names, fields=fields, ).splitlines() lines.append("") return lines def _build_summary_table_rst(grouped, spec_lookup, need_names=None): """Build RST lines for the result summary needtable.""" modules = sorted({ (spec_lookup.find(suite, fn) or {}).get("test_module", "") for suite, fn in grouped } - {""}) result_type = _need_name(need_names, "result") tbl_filter = ( f'type == "{result_type}" and test_module == "{modules[0]}"' if len(modules) == 1 else f'type == "{result_type}"' ) heading = "Result summary" return [ "----", "", heading, "-" * len(heading), "", ".. needtable::", f" :filter: {tbl_filter}", f" :columns: id, title, test_module, platform, scenario, status, " f"execution_time, {_need_name(need_names, 'result_of')}", " :style: table", "", ] def _build_exec_logs_rst(twister_out_dir, module_filter, path_filter=None): """Build RST lines for the execution logs section; returns [] when unavailable. Selects the same runs as the results above (`scenario_selected`), so a page never shows the log of a run whose results it does not show. """ twister_json = Path(twister_out_dir) / "twister.json" if twister_out_dir else None if not twister_json or not twister_json.exists(): return [] try: tw = load_twister_meta(twister_json) except Exception as exc: logger.warning(f"testreport: could not load execution logs: {exc}") return [] suite_paths = testsuite_paths(tw) log_entries = [] for ts in tw.get("testsuites", []): sname = ts["name"] if not scenario_selected( ts["platform"], sname, module_filter, path_filter=path_filter, suite_paths=suite_paths, ): continue log_entries.append((sname, ts["platform"], ts.get("path", ""), ts.get("toolchain", ""))) log_entries.sort() if not log_entries: return [] lines = ["----", "", "Execution Logs", "-" * len("Execution Logs"), ""] for scenario, platform, test_path, toolchain in log_entries: sub = f"{scenario} — {platform}" lines += [sub, "~" * len(sub), ""] log_file = find_handler_log(twister_out_dir, platform, toolchain, test_path, scenario) if log_file: lines += [".. code-block:: none", ""] for line in log_file.read_text(errors="replace").splitlines(): lines.append(" " + line) lines.append("") else: lines += [ f"*handler.log not found for* ``{scenario}`` *on* ``{platform}``", "", ] return lines # --------------------------------------------------------------------------- # twisterinfo helpers # --------------------------------------------------------------------------- def _compute_platform_stats(suites): """Compute per-platform pass/fail/skip/error counts. Returns (platforms, stats, totals) where stats is a list of (platform, passed, failed, skipped, error, total) tuples and totals is (total_passed, total_failed, total_skipped, total_error). """ platforms = sorted({s["platform"] for s in suites}) by_platform = defaultdict(list) for s in suites: by_platform[s["platform"]].extend(s.get("testcases", [])) stats, totals = [], [0, 0, 0, 0] for plat in platforms: counts = Counter(tc.get("status", "") for tc in by_platform[plat]) p = counts.get("passed", 0) f = counts.get("failed", 0) s = counts.get("skipped", 0) e = counts.get("error", 0) totals[0] += p totals[1] += f totals[2] += s totals[3] += e stats.append((plat, p, f, s, e, len(by_platform[plat]))) return platforms, stats, tuple(totals) def _build_twisterinfo_rst(tw_env, suites, project_name="", project_version=""): """Build RST lines for the run-metadata and per-platform summary tables.""" run_date_raw = tw_env.get("run_date", "") try: from datetime import datetime, timezone run_date = ( datetime.fromisoformat(run_date_raw) .astimezone(timezone.utc) .strftime("%Y-%m-%d %H:%M:%S UTC") ) except Exception: run_date = run_date_raw scenarios = sorted({s["name"] for s in suites}) platforms, stats, (tp, tf, ts, te) = _compute_platform_stats(suites) total = tp + tf + ts + te total_str = ( f"{total} (passed: {tp}" + (f", failed: {tf}" if tf else "") + (f", skipped: {ts}" if ts else "") + (f", error: {te}" if te else "") + ")" ) lines = [ ".. list-table:: Test Run Metadata", " :header-rows: 0", " :widths: 25 75", "", " * - Run date", f" - {run_date}", # Version under test. The VALUE comes from twister's own JSON # (`zephyr_version` is a key in twister's output schema, not branding — # reading it is no different from reading `testsuites`), so this row # stays consistent with every other row in this table, all of which # report facts about the run. A consumer that wants its own version # string instead can set `twisterinfo_project_version`; the LABEL is # likewise the consumer's via `twisterinfo_project_name`, which is what # de-branding this row actually required. Sourcing the value from # config alone dropped twister's reported version entirely. f" * - {project_name + ' version' if project_name else 'Version under test'}", f" - ``{project_version or tw_env.get('zephyr_version', '—')}``", " * - Toolchain", f" - {tw_env.get('toolchain', '—')}", " * - Host OS", f" - {tw_env.get('os', '—')}", " * - Test scenarios", f" - {', '.join(f'``{s}``' for s in scenarios)}", " * - Platforms", f" - {', '.join(f'``{p}``' for p in platforms)}", " * - Total test cases", f" - {total_str}", "", ".. list-table:: Results per Platform", " :header-rows: 1", " :widths: 50 15 15 10 10", "", " * - Platform", " - Passed", " - Failed", " - Skipped", " - Total", ] for plat, p, f, s, e, total_plat in stats: lines += [ f" * - ``{plat}``", f" - {p}", f" - {f + e}", f" - {s}", f" - {total_plat}", ] lines.append("") return lines # --------------------------------------------------------------------------- # TestModuleDirective # ---------------------------------------------------------------------------
[docs] class TestModuleDirective(Directive): """ Emit sphinx-needs test_case nodes for all ZTEST functions in a module group. Usage:: .. testmodule:: kernel_queue_module :module: tests/kernel/queue """ required_arguments = 1 optional_arguments = 0 has_content = False option_spec = { "module": directives.unchanged, } def run(self): group_name = self.arguments[0].strip() module_path = self.options.get("module", "").strip("/") env = self.state.document.settings.env app = env.app xml_dir = Path(app.config.testmodule_xml_dir) if not xml_dir.is_dir(): return [self.state_machine.reporter.error( f"testmodule: testmodule_xml_dir not found: {xml_dir}", nodes.literal_block(group_name, group_name), line=self.lineno, )] page_depth = len(Path(env.docname).parts) - 1 page_prefix = "../" * page_depth testspec_html_dir = page_prefix + app.config.testspec_doxygen_url api_html_dir = page_prefix + app.config.api_doxygen_url tag_dirs = _tag_dirs(getattr(app.config, "testmodule_tag_urls", {}), page_prefix) # testmodule_root is supplied by the engine (zdocs_conf.py, defaulting # to ZDOCS_PROJECT_BASE) — no ZEPHYR_BASE fallback: that was a # project-specific env var name in a generic engine (decision 5). module_root = app.config.testmodule_root # Every file read below lives outside the Sphinx source tree, so each # is noted as an input (see _note_input): without that, an incremental # build after the test sources change keeps the old test cases. _note_input(env, xml_dir / "index.xml") # Parsed once per build and cached on the app, which lives for one # build only. It used to be cached on env, which is pickled across # builds, so a module group added later stayed "not found" until a # full rebuild. group_index = getattr(app, "_testmodule_group_index", None) if group_index is None: try: group_index = app._testmodule_group_index = load_group_index(xml_dir) except RuntimeError as exc: logger.warning(str(exc)) return [nodes.paragraph(text=str(exc))] module_refid = group_index.get(group_name) if module_refid is None: logger.warning(f"testmodule: Doxygen group '{group_name}' not found in index.xml") return [ nodes.paragraph(text=f"[testmodule: group '{group_name}' not in Doxygen index]") ] module_group_xml = xml_dir / f"{module_refid}.xml" _note_input(env, module_group_xml) if not module_group_xml.exists(): logger.warning( f"testmodule: XML file not found for group '{group_name}': {module_group_xml}" ) return [nodes.paragraph(text=f"[testmodule: XML missing for '{group_name}']")] module_cdef = ET.parse(module_group_xml).getroot().find("compounddef") # _classify_inner_groups reads every inner group, and the suite and # procedure builders read theirs again: all of them are inputs. for inner in module_cdef.findall("innergroup"): _note_input(env, xml_dir / f"{inner.get('refid')}.xml") suite_refids, proc_refids = _classify_inner_groups(module_cdef, xml_dir) need_names = _need_names_from_config(app) # Each name twister accepts is an input, so a scenario file that is # added or renamed later also re-reads the document. module_dir = Path(module_root) / module_path for name in SCENARIO_YAML_NAMES: _note_input(env, module_dir / name) scenario_yaml = find_scenario_yaml(module_dir) scenario_lines = build_scenario_table(scenario_yaml) if scenario_yaml else [] all_rst = list(scenario_lines) for suite_refid in suite_refids: all_rst += _build_suite_rst( suite_refid, xml_dir, testspec_html_dir, api_html_dir, module_path, need_names=need_names, depends_field=lambda conditions, subject: depends_field( env, conditions, subject ), suite_qualifier=getattr(app.config, "testmodule_suite_qualifier", ""), tag_dirs=tag_dirs, ) for proc_refid in proc_refids: all_rst += _build_proc_group_rst( proc_refid, xml_dir, testspec_html_dir, api_html_dir, need_names=need_names, tag_dirs=tag_dirs, ) _maybe_dump_rst(app, env.docname, "testmodule", group_name, "\n".join(all_rst)) return _render_rst(all_rst, self.state, self.content_offset, match_titles=True)
# --------------------------------------------------------------------------- # TestReportDirective # ---------------------------------------------------------------------------
[docs] class TestReportDirective(Directive): """ Emit sphinx-needs test_result nodes from a twister_report.xml. Usage:: .. testreport:: twister_report.xml :path: tests/kernel/queue ``:path:`` selects the runs of one test directory, as twister.json records it; ``:module:`` selects by scenario-name prefix. With both, a run must match both. """ required_arguments = 1 optional_arguments = 0 has_content = False option_spec = { "module": directives.unchanged, "path": directives.unchanged, } def run(self): xml_path = self.arguments[0].strip() module_filter = self.options.get("module", "").strip() or None path_filter = self.options.get("path", "").strip() or None env = self.state.document.settings.env app = env.app if not Path(xml_path).is_absolute(): base = getattr(app.config, "twister_output_dir", "") or str( Path(env.doc2path(env.docname)).parent ) xml_path = str(Path(base) / xml_path) spec_json = getattr(app.config, "testspec_needs_json", "") if not spec_json: # Fallback: first external-needs source (legacy behaviour). ext_needs = getattr(app.config, "needs_external_needs", []) spec_json = ext_needs[0].get("json_path", "") if ext_needs else "" # Both inputs live outside the source tree, so the report must be told # to re-read when they change (see _note_input). Noted before the # existence checks on purpose: a report built ahead of its test run # has to pick the output up once it appears. _note_input(env, xml_path) if spec_json: _note_input(env, spec_json) if not spec_json or not Path(spec_json).exists(): msg = f"[testreport: spec needs.json not found: {spec_json!r}]" logger.warning(f"testreport: {msg}") # See _display_name's own docstring: the LOG line above keeps the # full absolute path (unchanged, on purpose); the PUBLISHED node # below must not. display_msg = f"[testreport: spec needs.json not found: {_display_name(spec_json)}]" return [nodes.paragraph(text=display_msg)] # Computed up front (not just below, alongside the RST builders) so # the spec lookup filters/reads the need TYPE and the "verifies" # LINK by the consumer's own configured names (step 26) rather than # the engine's literal defaults — the bug this step exists to fix. need_names = _need_names_from_config(app) try: spec_lookup = load_spec_lookup(spec_json, need_names=need_names) except Exception as exc: logger.warning(str(exc)) return [nodes.paragraph(text=str(exc))] if not Path(xml_path).exists(): msg = f"[testreport: twister XML not found: {xml_path}]" logger.warning(f"testreport: {msg}") # See _display_name's own docstring: the LOG line above keeps the # full absolute path (unchanged, on purpose); the PUBLISHED node # below must not. display_msg = f"[testreport: twister XML not found: {_display_name(xml_path)}]" return [nodes.paragraph(text=display_msg)] # twister.json, written beside the XML by the same run, has what the # XML lacks: each testsuite's path (needed for :path:) and statuses # such as `blocked` (shown for a parameterized test, see below). twister_json = Path(xml_path).parent / "twister.json" _note_input(env, twister_json) tw_meta = None if twister_json.exists(): try: tw_meta = load_twister_meta(twister_json) except Exception as exc: logger.warning(f"testreport: cannot read {twister_json}: {exc}") if path_filter is not None: return [nodes.paragraph(text=f"[testreport: cannot read {twister_json.name}]")] elif path_filter is not None: logger.warning( f"testreport: :path: needs twister.json beside the report, " f"not found: {twister_json}" ) return [nodes.paragraph( text=f"[testreport: twister.json not found: {twister_json.name} " f"(needed for :path:)]" )] suite_paths = testsuite_paths(tw_meta) if tw_meta else None try: results = parse_twister_results( xml_path, module_filter, path_filter=path_filter, suite_paths=suite_paths ) except Exception as exc: logger.warning(str(exc)) return [nodes.paragraph(text=str(exc))] # One result per parameter value becomes part of its test's result. results, unmatched = fold_parameterized_results( results, spec_lookup, testcase_statuses(tw_meta) if tw_meta else None ) for fn in unmatched: logger.warning( f"testreport: parameterized test '{fn}' has value results but no " f"aggregate result and no unique spec case — its values are skipped" ) if not results: return [nodes.paragraph(text="[testreport: no matching results]")] # Each build's .config lives in the run directory the report is in. unparseable = _assess_results( results, spec_lookup, tw_meta, Path(xml_path).parent, note_input=lambda path: _note_input(env, path), ) for case_id, condition in sorted(unparseable): logger.warning( f"testreport: {case_id}: depends_on condition {condition!r} is not " f"a Kconfig expression zdocs evaluates — its results get depends_met n/a" ) suite_order, func_order, grouped = _group_results(results) twister_out_dir = getattr(app.config, "twister_output_dir", "") all_rst = ( _build_results_rst( suite_order, func_order, grouped, spec_lookup, need_names=need_names, fields=_declared_result_fields(env, need_names), ) + _build_summary_table_rst(grouped, spec_lookup, need_names=need_names) + _build_exec_logs_rst(twister_out_dir, module_filter, path_filter) ) _maybe_dump_rst( app, env.docname, "testreport", path_filter or module_filter or "", "\n".join(all_rst) ) return _render_rst(all_rst, self.state, self.content_offset, match_titles=True)
# --------------------------------------------------------------------------- # TwisterInfoDirective # ---------------------------------------------------------------------------
[docs] class TwisterInfoDirective(Directive): """Emit a run-metadata block and per-platform summary table from twister.json. Usage:: .. twisterinfo:: twister.json """ required_arguments = 1 optional_arguments = 0 has_content = False option_spec = {} def run(self): json_path = self.arguments[0].strip() env = self.state.document.settings.env app = env.app if not Path(json_path).is_absolute(): base = getattr(app.config, "twister_output_dir", "") or str( Path(env.doc2path(env.docname)).parent ) json_path = str(Path(base) / json_path) # See TestReportDirective.run: an input outside the source tree. _note_input(env, json_path) if not Path(json_path).exists(): msg = f"[twisterinfo: twister.json not found: {json_path!r}]" logger.warning(f"twisterinfo: {msg}") # See _display_name's own docstring: the LOG line above keeps the # full absolute path (unchanged, on purpose); the PUBLISHED node # below must not. display_msg = f"[twisterinfo: twister.json not found: {_display_name(json_path)}]" return [nodes.paragraph(text=display_msg)] try: data = load_twister_meta(json_path) except Exception as exc: logger.warning(f"twisterinfo: cannot read {json_path}: {exc}") return [nodes.paragraph(text=str(exc))] lines = _build_twisterinfo_rst( data.get("environment", {}), data.get("testsuites", []), project_name=getattr(app.config, "twisterinfo_project_name", ""), project_version=getattr(app.config, "twisterinfo_project_version", ""), ) _maybe_dump_rst(app, env.docname, "twisterinfo", Path(json_path).name, "\n".join(lines)) return _render_rst(lines, self.state, self.content_offset)
# --------------------------------------------------------------------------- # Extension setup # --------------------------------------------------------------------------- def setup(app): app.add_config_value("testmodule_xml_dir", "", "env") app.add_config_value("testspec_needs_json", "", "env") app.add_config_value("testspec_doxygen_url", "", "env") app.add_config_value("api_doxygen_url", "", "env") # {Doxygen tag file path: HTML directory of its document}, from the # registry (docrefs.tag_urls): a reference Doxygen resolved through a tag # file links into the document that tag file belongs to. app.add_config_value("testmodule_tag_urls", {}, "env") # requirements_url deliberately NOT registered (decision 4): it was # computed at conf_test_common.py:56 and consumed nowhere in any of the # four modules — deleted outright, not migrated and left unset. app.add_config_value("twister_output_dir", "", "env") app.add_config_value("testmodule_root", "", "env") app.add_config_value("twisterinfo_project_name", "", "env") app.add_config_value("twisterinfo_project_version", "", "env") app.add_config_value("dump_generated_rst", "", "env") # Separator in a suite group's Doxygen name: the need's `suite` is the part # after its last occurrence (suite_name_from_group). Empty = the whole name. app.add_config_value("testmodule_suite_qualifier", "", "env") app.add_config_value( "testmodule_need_types", {"case": "test_case", "procedure": "test_procedure", "result": "test_result"}, "env", ) app.add_config_value( "testmodule_need_links", {"verifies": "verifies", "result_of": "result_of", "covers": "covers"}, "env", ) # Result FIELD roles -> names; a field is set only where the consumer # declared it under that name (see _declared_result_fields). app.add_config_value( "testreport_need_fields", {"depends_met": "depends_met", "skip_class": "skip_class"}, "env", ) app.add_directive("testmodule", TestModuleDirective) app.add_directive("testreport", TestReportDirective) app.add_directive("twisterinfo", TwisterInfoDirective) app.setup_extension("input_tracking") # The testcoverage directive: coverage adequacy beside the test results. app.setup_extension("test_coverage") return {"version": "0.2", "parallel_read_safe": True}