Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 40 additions & 33 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from utilities.junit_ai_utils import enrich_junit_xml, setup_ai_analysis
from utilities.logger import setup_logging
from utilities.pytest_utils import (
_inject_failure_junit,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test Execution Plan

  • Run smoke tests: True — every smoke invocation reaches conftest.py::pytest_sessionfinish_inject_failure_junit(). This validates that normal JUnit output is preserved when _failure_info is unset; the tests/virt and tests/infrastructure smoke paths can also reach their session-level sanity fixtures → exit_pytest_execution() on an unmet cluster prerequisite.
  • Run gating tests: True — every gating invocation reaches conftest.py::pytest_sessionfinish_inject_failure_junit(). In addition, gating tests below tests/network, tests/virt, tests/infrastructure, and tests/install_upgrade_operators are governed by directory conftests whose session fixtures call exit_pytest_execution() → records _failure_info → the new synthetic JUnit error testcase.

Affected tests to run

  • utilities/unittests/test_pytest_utils.py::TestInjectFailureJunit
  • -m smoke (smoke)
  • -m gating (gating)

Real tests (cluster required)

Error path (the fix; use a cluster intentionally missing the SR-IOV prerequisite):

pytest tests/network -m sriov --junitxml=/tmp/pytest-exit.xml

Expected: the network session-sanity early exit creates a parseable /tmp/pytest-exit.xml with a pytest_exit testcase, an <error> containing the failure details, and incremented tests/errors counts.

Happy path (regression):

pytest tests/virt/node/general/test_container_disk_vm.py --junitxml=/tmp/pytest-success.xml

Expected: the test completes normally and /tmp/pytest-success.xml remains valid without a synthetic pytest_exit testcase.

assert_incremental_classes_fully_collected,
config_default_storage_class,
deploy_run_in_progress_config_map,
Expand Down Expand Up @@ -870,41 +871,47 @@ def pytest_collection_finish(session):


def pytest_sessionfinish(session, exitstatus):
shutil.rmtree(path=session.config.option.basetemp, ignore_errors=True)
if not skip_if_pytest_flags_exists(pytest_config=session.config):
admin_client = utilities.cluster.cache_admin_client()
run_in_progress_config_map(client=admin_client).clean_up()
deploy_run_in_progress_namespace(client=admin_client).clean_up()

reporter = session.config.pluginmanager.get_plugin("terminalreporter")
reporter.summary_stats()
if session.config.getoption("--data-collector"):
db = Database(base_dir=session.config.getoption("--data-collector-output-dir"))
file_path = db.database_file_path
LOGGER.info(f"Removing database file path {file_path}")
os.remove(file_path)
# clean up the empty folders
collector_directory = py_config["data_collector"]["data_collector_base_directory"]
if os.path.exists(collector_directory):
for root, dirs, files in os.walk(collector_directory, topdown=False):
for _dir in dirs:
dir_path = os.path.join(root, _dir)
if not os.listdir(dir_path):
shutil.rmtree(dir_path, ignore_errors=True)

# Enrich JUnit XML with AI analysis after all tests complete.
# Source: https://github.com/myk-org/jenkins-job-insight/blob/main/examples/pytest-junitxml/conftest_junit_ai.py
if session.config.option.analyze_with_ai:
if exitstatus == 0:
LOGGER.info("No test failures (exit code %d), skipping AI analysis", exitstatus)
try:
shutil.rmtree(path=session.config.option.basetemp, ignore_errors=True)
if not skip_if_pytest_flags_exists(pytest_config=session.config):
admin_client = utilities.cluster.cache_admin_client()
run_in_progress_config_map(client=admin_client).clean_up()
deploy_run_in_progress_namespace(client=admin_client).clean_up()

reporter = session.config.pluginmanager.get_plugin("terminalreporter")
reporter.summary_stats()
if session.config.getoption("--data-collector"):
db = Database(base_dir=session.config.getoption("--data-collector-output-dir"))
file_path = db.database_file_path
LOGGER.info(f"Removing database file path {file_path}")
os.remove(file_path)
# clean up the empty folders
collector_directory = py_config["data_collector"]["data_collector_base_directory"]
if os.path.exists(collector_directory):
for root, dirs, files in os.walk(collector_directory, topdown=False):
for _dir in dirs:
dir_path = os.path.join(root, _dir)
if not os.listdir(dir_path):
shutil.rmtree(dir_path, ignore_errors=True)

# Enrich JUnit XML with AI analysis after all tests complete.
# Source: https://github.com/myk-org/jenkins-job-insight/blob/main/examples/pytest-junitxml/conftest_junit_ai.py
if session.config.option.analyze_with_ai:
if exitstatus == 0:
LOGGER.info("No test failures (exit code %d), skipping AI analysis", exitstatus)

else:
try:
enrich_junit_xml(session)
except Exception:
LOGGER.exception("Failed to enrich JUnit XML, original preserved")
else:
try:
enrich_junit_xml(session)
except Exception:
LOGGER.exception("Failed to enrich JUnit XML, original preserved")
finally:
try:
_inject_failure_junit(session=session)
except Exception:
LOGGER.exception("Failed to inject failure into JUnit XML")

session.config.option.log_listener.stop()
session.config.option.log_listener.stop()


def get_all_node_markers(node: Node) -> list[str]:
Expand Down
102 changes: 101 additions & 1 deletion utilities/pytest_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,20 @@
import shutil
import socket
import sys
import tempfile
from collections import defaultdict
from typing import Any
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
from typing import TypedDict

class _FailureInfoDict(TypedDict):
message: str
log_message: str
return_code: int


from xml.etree import ElementTree

import pytest
from kubernetes.dynamic import DynamicClient
Expand Down Expand Up @@ -52,6 +64,81 @@
LOGGER = logging.getLogger(__name__)


_failure_info: _FailureInfoDict | None = None


def _inject_failure_junit(session: pytest.Session) -> None:
"""Inject a synthetic error testcase into JUnit XML for pytest exit failures.

When exit_pytest_execution aborts the session, the exit reason may not appear
in the JUnit XML report. CI systems can miss the failure — either because the
report is empty (no tests ran) or because the exit reason is not captured as a
testcase. This function re-opens the XML file after LogXML writes it and adds
a synthetic error testcase to ensure the exit failure is always reported.

Note:
Must be called during pytest_sessionfinish, after LogXML has written the
XML file. Pluggy calls hooks in LIFO (last-in-first-out) registration
order: conftest is registered before the junitxml plugin, so the
conftest hook runs after LogXML has written its output.

Args:
session: The pytest session object, used to access the junitxml file path.
"""
if _failure_info is None:
return

xml_path = getattr(session.config.option, "xmlpath", None)
if not xml_path or not os.path.exists(xml_path):
LOGGER.info("No JUnit XML file found, skipping synthetic testcase injection")
return
Comment thread
rnetser marked this conversation as resolved.

return_code = _failure_info["return_code"]
log_message = _failure_info["log_message"]

sanitized_name = re.sub(r"[^a-z0-9_]", "", _failure_info["message"].lower().replace(" ", "_"))
sanitized_name = re.sub(r"_+", "_", sanitized_name).strip("_")[:80]
name = sanitized_name or "execution_failure"

tree = ElementTree.parse(xml_path)
root = tree.getroot()

testsuite = root.find("testsuite")
if testsuite is None:
LOGGER.warning("No <testsuite> element found in JUnit XML, skipping injection")
return

testcase = ElementTree.SubElement(
testsuite,
"testcase",
attrib={"classname": "pytest_exit", "name": name, "time": "0.000"},
)
error_node = ElementTree.SubElement(
testcase,
"error",
attrib={"message": f"Pytest execution failed (exit code: {return_code})"},
)
# Strip XML 1.0 illegal control characters — ElementTree passes them through, corrupting the file.
error_node.text = re.sub(r"[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\U00010000-\U0010FFFF]", "\ufffd", log_message)

current_errors = int(testsuite.get("errors", "0"))
testsuite.set("errors", str(current_errors + 1))
current_tests = int(testsuite.get("tests", "0"))
testsuite.set("tests", str(current_tests + 1))

xml_dir = os.path.dirname(os.path.abspath(xml_path))
fd, tmp_path = tempfile.mkstemp(dir=xml_dir, suffix=".xml")
try:
with os.fdopen(fd, mode="w") as tmp_file:
tree.write(tmp_file, encoding="unicode", xml_declaration=True)
os.replace(tmp_path, xml_path)
except BaseException:
os.unlink(tmp_path)
raise

LOGGER.info(f"Injected synthetic failure testcase into JUnit XML (exit code: {return_code})")


def get_base_matrix_name(matrix_name):
match = re.match(r".*?(.*?_matrix)_(?:.*_matrix)+", matrix_name)
if match:
Expand Down Expand Up @@ -424,6 +511,11 @@ def exit_pytest_execution(
junitxml_property (pytest plugin): record_testsuite_property
message (str): Message to log in an error file. If not provided, `log_message` will be used.
admin_client (DynamicClient): cluster admin client

Note:
Records the failure details in a module-level store so that
_inject_failure_junit can emit a synthetic JUnit XML testcase
during pytest_sessionfinish.
"""
target_location = os.path.join(get_data_collector_base_directory(), "utilities", "pytest_exit_errors")
# collect must-gather for past 5 minutes:
Expand All @@ -443,6 +535,14 @@ def exit_pytest_execution(
)
if junitxml_property:
junitxml_property(name="exit_code", value=return_code)

global _failure_info
_failure_info = {
"message": message or log_message,
"log_message": log_message,
"return_code": return_code,
}

pytest.exit(reason=log_message, returncode=return_code)


Expand Down
Loading
Loading