diff --git a/docs/configuration.rst b/docs/configuration.rst index 68eb026..ae19e77 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -138,6 +138,65 @@ You can then use these options in your directives: This test file contains enhanced metadata using custom extra options. +**Mapping JUnit XML ```` to fields** + +When importing JUnit XML files, ``tr_extra_options`` also controls which ```` +elements inside ```` and ```` blocks are surfaced as sphinx-needs +fields. Only property names listed here are imported; all others are silently ignored. + +.. code-block:: xml + + + + + + + + + +.. code-block:: python + + # conf.py – surface "priority" as a sphinx-needs field + tr_extra_options = ["priority"] + needs_extra_options = ["priority"] + +See :ref:`tr_property_link_types` to map properties to sphinx-needs link fields instead. + +.. _tr_property_link_types: + +tr_property_link_types +---------------------- +.. versionadded:: 1.3.0 + +Maps a JUnit XML ```` name to a sphinx-needs link field. When a test case or +suite contains a matching property, its comma-separated values are converted to +semicolon-separated need IDs and merged into the specified link field. + +.. code-block:: python + + # conf.py + tr_property_link_types = { + "verifies": "links", # comma-separated IDs → need links + } + +With this configuration a ```` in +a ```` results in ``links: REQ_001;REQ_002`` on the generated need. + +Multiple properties can map to different link fields: + +.. code-block:: python + + tr_property_link_types = { + "verifies": "links", + "blocks": "blocks_back", + } + +.. note:: + + The property name does **not** need to be listed in :ref:`tr_extra_options` when it + is used exclusively for link mapping. Only add it there if you also want the raw + value to appear as a plain text field on the need. + .. _tr_import_encoding: tr_import_encoding diff --git a/sphinxcontrib/test_reports/config.py b/sphinxcontrib/test_reports/config.py index eb2068f..91bf544 100644 --- a/sphinxcontrib/test_reports/config.py +++ b/sphinxcontrib/test_reports/config.py @@ -13,4 +13,5 @@ "text", "message", "system-out", + "properties", ] diff --git a/sphinxcontrib/test_reports/directives/test_case.py b/sphinxcontrib/test_reports/directives/test_case.py index dd81fc0..d8f2fef 100644 --- a/sphinxcontrib/test_reports/directives/test_case.py +++ b/sphinxcontrib/test_reports/directives/test_case.py @@ -160,6 +160,19 @@ def run(self, nested=False, suite_count=-1, case_count=-1): if case_parameter is None: case_parameter = "" + # Flatten JUnit into top-level case keys so that + # the extra-data loop below picks them up as sphinx-needs fields. + # Only propagate properties that are explicitly listed in + # tr_extra_options to avoid unknown-kwarg errors from add_need. + # Properties do not overwrite core JUnit attributes (name, time, etc.). + allowed_extras = set(getattr(self.app.config, "tr_extra_options", [])) + case_properties = case.get("properties", {}) + for prop_name, prop_value in case_properties.items(): + if prop_name in allowed_extras and prop_name not in case: + case[prop_name] = prop_value + + self._apply_property_links(case_properties) + # Set extra data, which is not part of the Sphinx-Test-Reports default options for key, value in case.items(): if key == "id" and value not in ["", None]: diff --git a/sphinxcontrib/test_reports/directives/test_common.py b/sphinxcontrib/test_reports/directives/test_common.py index a0300ed..9b09978 100644 --- a/sphinxcontrib/test_reports/directives/test_common.py +++ b/sphinxcontrib/test_reports/directives/test_common.py @@ -155,3 +155,22 @@ def prepare_basic_options(self): # Also collect any extra options while we're at it self.collect_extra_options() + + def _apply_property_links(self, properties): + """Map JUnit values to sphinx-needs link fields via tr_property_link_types.""" + tr_property_link_types = getattr(self.app.config, "tr_property_link_types", {}) + for prop_name, link_field in tr_property_link_types.items(): + prop_value = properties.get(prop_name, "") + if prop_value: + link_ids = ";".join( + id_val.strip() for id_val in prop_value.split(",") if id_val.strip() + ) + if link_field == "links": + existing = self.test_links + else: + existing = self.extra_options.get(link_field, "") + merged = existing + ";" + link_ids if existing else link_ids + if link_field == "links": + self.test_links = merged + else: + self.extra_options[link_field] = merged diff --git a/sphinxcontrib/test_reports/directives/test_suite.py b/sphinxcontrib/test_reports/directives/test_suite.py index 13b0411..d2882d1 100644 --- a/sphinxcontrib/test_reports/directives/test_suite.py +++ b/sphinxcontrib/test_reports/directives/test_suite.py @@ -73,6 +73,22 @@ def run(self, nested=False, count=-1): errors = suite["errors"] failed = suite["failures"] + # Flatten JUnit into extra_options so that + # suite-level properties are surfaced as sphinx-needs fields. + # Only propagate properties that are explicitly listed in + # tr_extra_options to avoid unknown-kwarg errors from add_need. + allowed_extras = set(getattr(self.app.config, "tr_extra_options", [])) + suite_properties = suite.get("properties", {}) + for prop_name, prop_value in suite_properties.items(): + if ( + prop_name in allowed_extras + and prop_name not in suite + and prop_value not in ["", None] + ): + self.extra_options[prop_name] = str(prop_value) + + self._apply_property_links(suite_properties) + main_section = [] docname = self.state.document.settings.env.docname main_section += add_need( diff --git a/sphinxcontrib/test_reports/junitparser.py b/sphinxcontrib/test_reports/junitparser.py index 7430f6a..d326c00 100644 --- a/sphinxcontrib/test_reports/junitparser.py +++ b/sphinxcontrib/test_reports/junitparser.py @@ -83,6 +83,18 @@ def parse_testcase(xml_object): else: tc_dict["system-out"] = "" + # Extract child elements as a dict + props = {} + if hasattr(testcase, "properties") and hasattr( + testcase.properties, "property" + ): + for prop in testcase.properties.property: + name = prop.attrib.get("name", "") + value = prop.attrib.get("value", "") + if name: + props[name] = value + tc_dict["properties"] = props + return tc_dict def parse_testsuite(xml_object): @@ -112,6 +124,18 @@ def parse_testsuite(xml_object): "testsuite_nested": [], } + # Extract child elements as a dict + props = {} + if hasattr(testsuite, "properties") and hasattr( + testsuite.properties, "property" + ): + for prop in testsuite.properties.property: + name = prop.attrib.get("name", "") + value = prop.attrib.get("value", "") + if name: + props[name] = value + ts_dict["properties"] = props + # add nested testsuite objects to if hasattr(testsuite, "testsuite"): for ts in testsuite.testsuite: diff --git a/sphinxcontrib/test_reports/test_reports.py b/sphinxcontrib/test_reports/test_reports.py index ec30890..76cce61 100644 --- a/sphinxcontrib/test_reports/test_reports.py +++ b/sphinxcontrib/test_reports/test_reports.py @@ -63,7 +63,13 @@ def _register_field(app, name, schema=None): description = FIELD_DESCRIPTIONS.get(name, name) - _add_field(name, description, schema=schema) + try: + _add_field(name, description, schema=schema) + except Exception: + # Field already registered (e.g. via needs_fields or needs_extra_options + # in conf.py). Skip to avoid duplicate registration errors. + log = logging.getLogger(__name__) + log.debug(f"Field '{name}' already registered, skipping") except ImportError: from sphinx_needs.api import add_extra_option as _add_extra_option @@ -78,7 +84,11 @@ def _register_field(app, name, schema=None): kwargs["description"] = FIELD_DESCRIPTIONS.get(name, name) if schema is not None: kwargs["schema"] = schema - _add_extra_option(app, name, **kwargs) + try: + _add_extra_option(app, name, **kwargs) + except Exception: + log = logging.getLogger(__name__) + log.debug(f"Field '{name}' already registered, skipping") def setup(app: Sphinx): @@ -122,6 +132,7 @@ def setup(app: Sphinx): app.add_config_value("tr_case_id_length", 5, "html") app.add_config_value("tr_import_encoding", "utf8", "html") app.add_config_value("tr_extra_options", [], "env") + app.add_config_value("tr_property_link_types", {}, "env") json_mapping = { "json_config": { @@ -258,6 +269,15 @@ def sphinx_needs_update(app: Sphinx, config: Config) -> None: # https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_dynamic_function add_dynamic_function(app, tr_link) + # Register tr_extra_options as sphinx-needs fields so that properties + # extracted from JUnit XML are accepted by sphinx-needs + tr_extra_options = getattr(config, "tr_extra_options", []) + for option_name in tr_extra_options: + if use_schema: + _register_field(app, option_name, schema={"type": "string"}) + else: + _register_field(app, option_name) + # Extra need types # For details about usage read # https://sphinx-needs.readthedocs.io/en/latest/api.html#sphinx_needs.api.configuration.add_need_type diff --git a/tests/doc_test/properties_linking/conf.py b/tests/doc_test/properties_linking/conf.py new file mode 100644 index 0000000..b3d3dd3 --- /dev/null +++ b/tests/doc_test/properties_linking/conf.py @@ -0,0 +1,48 @@ +import os +import sys + +import sphinx_needs +from packaging.version import Version + +sys.path.insert(0, os.path.abspath("../../sphinxcontrib")) + +extensions = ["sphinx_needs", "sphinxcontrib.test_reports"] + +needs_types = [ + { + "directive": "req", + "title": "Requirement", + "prefix": "REQ_", + "color": "#BFD8D2", + "style": "node", + }, +] + +source_suffix = ".rst" +master_doc = "index" + +project = "properties-linking-test" +copyright = "2026, test" +author = "test" +version = "1.0" +release = "1.0" +language = "en" +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +html_theme = "alabaster" + +# Register "verifies" as both a sphinx-needs field and a tr_extra_option +if Version(sphinx_needs.__version__) >= Version("8.0.0"): + needs_fields = { + "verifies": {"nullable": True}, + "priority": {"nullable": True}, + "category": {"nullable": True}, + } +else: + needs_extra_options = ["verifies", "priority", "category"] + +tr_extra_options = ["verifies", "priority", "category"] + +# Map the "verifies" property to sphinx-needs links +tr_property_link_types = { + "verifies": "links", +} diff --git a/tests/doc_test/properties_linking/index.rst b/tests/doc_test/properties_linking/index.rst new file mode 100644 index 0000000..835cc13 --- /dev/null +++ b/tests/doc_test/properties_linking/index.rst @@ -0,0 +1,32 @@ +Properties Linking Test +======================= + +Requirements +------------ + +.. req:: Authentication Login + :id: REQ_AUTH_001 + +.. req:: Authentication Session + :id: REQ_AUTH_002 + +.. req:: Authentication Invalid + :id: REQ_AUTH_003 + +.. req:: API Get Users + :id: REQ_API_010 + +.. req:: API List Users + :id: REQ_API_011 + +.. req:: API Filter Users + :id: REQ_API_012 + +Test Results +------------ + +.. test-file:: Auth and API Tests + :id: PROP_TF_1 + :file: ../utils/xml_data_properties.xml + :auto_suites: + :auto_cases: diff --git a/tests/doc_test/utils/xml_data_empty_properties.xml b/tests/doc_test/utils/xml_data_empty_properties.xml new file mode 100644 index 0000000..af44f97 --- /dev/null +++ b/tests/doc_test/utils/xml_data_empty_properties.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/doc_test/utils/xml_data_properties.xml b/tests/doc_test/utils/xml_data_properties.xml new file mode 100644 index 0000000..858ed6d --- /dev/null +++ b/tests/doc_test/utils/xml_data_properties.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + Expected redirect to /login + + + + + + + + + + + + + + + diff --git a/tests/test_properties.py b/tests/test_properties.py new file mode 100644 index 0000000..446365c --- /dev/null +++ b/tests/test_properties.py @@ -0,0 +1,384 @@ +"""Tests for JUnit XML extraction and sphinx-needs integration.""" + +import os +from pathlib import Path + +import pytest + +xml_properties_path = os.path.join( + os.path.dirname(__file__), "doc_test/utils", "xml_data_properties.xml" +) +xml_no_properties_path = os.path.join( + os.path.dirname(__file__), "doc_test/utils", "xml_data.xml" +) +xml_empty_properties_path = os.path.join( + os.path.dirname(__file__), "doc_test/utils", "xml_data_empty_properties.xml" +) + + +# --------------------------------------------------------------------------- +# Parser-level unit tests +# --------------------------------------------------------------------------- + + +class TestParserExtractsTestcaseProperties: + """JUnitParser must extract from elements.""" + + def test_testcase_with_properties_returns_dict(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # First suite, first testcase has verifies + priority + tc = results[0]["testcases"][0] + assert tc["name"] == "test_login_valid" + assert isinstance(tc["properties"], dict) + assert tc["properties"]["verifies"] == "REQ_AUTH_001,REQ_AUTH_002" + assert tc["properties"]["priority"] == "high" + + def test_testcase_with_single_property(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # First suite, second testcase has only verifies + tc = results[0]["testcases"][1] + assert tc["name"] == "test_login_invalid" + assert tc["properties"] == {"verifies": "REQ_AUTH_003"} + + def test_testcase_without_properties_returns_empty_dict(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # First suite, third testcase (test_logout) has failure but no properties + tc = results[0]["testcases"][2] + assert tc["name"] == "test_logout" + assert tc["properties"] == {} + + def test_testcase_skipped_without_properties_returns_empty_dict(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # First suite, fourth testcase (test_session_timeout) is skipped, no properties + tc = results[0]["testcases"][3] + assert tc["name"] == "test_session_timeout" + assert tc["result"] == "skipped" + assert tc["properties"] == {} + + def test_multiple_suites_testcase_properties(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # Second suite, first testcase has verifies + category + tc = results[1]["testcases"][0] + assert tc["name"] == "test_get_users" + assert tc["properties"]["verifies"] == "REQ_API_010,REQ_API_011,REQ_API_012" + assert tc["properties"]["category"] == "integration" + + def test_testcase_without_any_properties_element(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # Second suite, second testcase has no properties at all + tc = results[1]["testcases"][1] + assert tc["name"] == "test_create_user" + assert tc["properties"] == {} + + +class TestParserExtractsTestsuiteProperties: + """JUnitParser must extract from elements.""" + + def test_testsuite_with_properties(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # First suite has environment + build_id properties + suite = results[0] + assert suite["name"] == "acceptance_tests" + assert isinstance(suite["properties"], dict) + assert suite["properties"]["environment"] == "staging" + assert suite["properties"]["build_id"] == "build-7742" + + def test_testsuite_without_properties(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_properties_path) + results = parser.parse() + + # Second suite has no properties + suite = results[1] + assert suite["name"] == "api_tests" + assert suite["properties"] == {} + + +class TestParserBackwardCompatibility: + """Adding properties extraction must not break existing XML without properties.""" + + def test_existing_xml_still_parses(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_no_properties_path) + results = parser.parse() + + assert len(results) == 1 + assert results[0]["name"] == "unknown" + assert results[0]["tests"] == 3 + assert results[0]["properties"] == {} + + for tc in results[0]["testcases"]: + assert tc["properties"] == {} + + def test_existing_testcase_fields_unchanged(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_no_properties_path) + results = parser.parse() + + tc = results[0]["testcases"][0] + assert tc["name"] == "ASuccessfulTest" + assert tc["classname"] == "foo1" + assert tc["result"] == "passed" + + def test_existing_failure_testcase_unchanged(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_no_properties_path) + results = parser.parse() + + tc = results[0]["testcases"][2] + assert tc["name"] == "AFailingTest" + assert tc["result"] == "failure" + assert tc["text"] == " details about failure " + + +# --------------------------------------------------------------------------- +# Integration tests: Sphinx build with properties linking +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/properties_linking"}], + indirect=True, +) +class TestPropertiesLinkingIntegration: + """Full Sphinx build that uses JUnit properties for sphinx-needs linking.""" + + def test_build_succeeds(self, test_app): + app = test_app + app.build() + assert app.statuscode == 0 + + def test_html_contains_test_cases(self, test_app): + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + + # Test case names from the XML should appear in the output + assert "test_login_valid" in html + assert "test_login_invalid" in html + assert "test_get_users" in html + + def test_verifies_field_rendered(self, test_app): + """The 'verifies' property should appear as a sphinx-needs field value.""" + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + + # The verifies values should be rendered somewhere in the output + assert "REQ_AUTH_001" in html + assert "REQ_AUTH_003" in html + assert "REQ_API_010" in html + + def test_links_created_from_properties(self, test_app): + """tr_property_link_types should create sphinx-needs links to requirement needs.""" + from bs4 import BeautifulSoup + + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + soup = BeautifulSoup(html, "html.parser") + + # Find links to requirement needs -- sphinx-needs renders outgoing links + # as anchor tags with the target need ID + req_links = soup.find_all("a", string=lambda s: s and s.startswith("REQ_")) + req_link_texts = [a.get_text(strip=True) for a in req_links] + + # Test cases with verifies property should link to requirement needs + assert "REQ_AUTH_001" in req_link_texts + assert "REQ_AUTH_002" in req_link_texts + assert "REQ_AUTH_003" in req_link_texts + assert "REQ_API_010" in req_link_texts + assert "REQ_API_011" in req_link_texts + assert "REQ_API_012" in req_link_texts + + def test_requirement_needs_present(self, test_app): + """Requirement needs defined in RST should be rendered.""" + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + + assert "Authentication Login" in html + assert "API Get Users" in html + + def test_testcases_without_properties_still_render(self, test_app): + """Test cases that have no properties should render without errors.""" + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + + # test_logout has a failure but no properties + assert "test_logout" in html + # test_create_user has no properties and no failure + assert "test_create_user" in html + + def test_priority_field_rendered(self, test_app): + """Non-link properties should be surfaced as sphinx-needs fields.""" + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + + # The priority property from test_login_valid should appear + assert "high" in html + + def test_category_field_rendered(self, test_app): + """The category property from test_get_users should appear.""" + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + + assert "integration" in html + + +# --------------------------------------------------------------------------- +# Parser: empty element (no children) +# --------------------------------------------------------------------------- + + +class TestParserHandlesEmptyProperties: + """JUnitParser must not crash on empty elements.""" + + def test_empty_testsuite_properties_element(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_empty_properties_path) + results = parser.parse() + + suite = results[0] + assert suite["name"] == "empty_props_suite" + assert suite["properties"] == {} + + def test_empty_testcase_properties_element(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_empty_properties_path) + results = parser.parse() + + tc = results[0]["testcases"][0] + assert tc["name"] == "test_with_empty_properties" + assert tc["properties"] == {} + + def test_testcase_without_properties_alongside_empty(self): + from sphinxcontrib.test_reports.junitparser import JUnitParser + + parser = JUnitParser(xml_empty_properties_path) + results = parser.parse() + + tc = results[0]["testcases"][1] + assert tc["name"] == "test_without_properties_element" + assert tc["properties"] == {} + + +# --------------------------------------------------------------------------- +# Integration: precise DOM assertions for property fields +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/properties_linking"}], + indirect=True, +) +class TestPropertyFieldsDomRendering: + """Verify property values are rendered as sphinx-needs fields using DOM checks.""" + + def test_priority_field_in_needs_data_span(self, test_app): + """The 'priority' property must appear inside a needs_data span.""" + from bs4 import BeautifulSoup + + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + soup = BeautifulSoup(html, "html.parser") + + priority_spans = soup.select("span.needs_priority span.needs_data") + priority_values = [s.get_text(strip=True) for s in priority_spans] + assert "high" in priority_values + + def test_category_field_in_needs_data_span(self, test_app): + """The 'category' property must appear inside a needs_data span.""" + from bs4 import BeautifulSoup + + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + soup = BeautifulSoup(html, "html.parser") + + category_spans = soup.select("span.needs_category span.needs_data") + category_values = [s.get_text(strip=True) for s in category_spans] + assert "integration" in category_values + + def test_verifies_field_in_needs_data_span(self, test_app): + """The 'verifies' property must appear inside a needs_data span.""" + from bs4 import BeautifulSoup + + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + soup = BeautifulSoup(html, "html.parser") + + verifies_spans = soup.select("span.needs_verifies span.needs_data") + verifies_values = [s.get_text(strip=True) for s in verifies_spans] + # test_login_valid has verifies="REQ_AUTH_001,REQ_AUTH_002" + assert any("REQ_AUTH_001" in v for v in verifies_values) + assert any("REQ_AUTH_002" in v for v in verifies_values) + + +# --------------------------------------------------------------------------- +# Integration: XML without any properties still builds successfully +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "test_app", + [{"buildername": "html", "srcdir": "doc_test/basic_doc"}], + indirect=True, +) +class TestNoPropertiesStillWorks: + """Sphinx build with JUnit XML that has no must still succeed.""" + + def test_build_succeeds_without_properties(self, test_app): + app = test_app + app.build() + assert app.statuscode == 0 + + def test_testcases_rendered_without_properties(self, test_app): + app = test_app + app.build() + html = Path(app.outdir, "index.html").read_text(encoding="utf-8") + + # The existing XML fixture (xml_data.xml) has these test case names + assert "ASuccessfulTest" in html + assert "AFailingTest" in html