diff --git a/tests/conftest.py b/tests/conftest.py index 4735ef99bf..8b703fe2ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import os import sys import pytest from panda3d import core @@ -99,3 +100,125 @@ def gsg(graphics_pipe, graphics_engine): if buffer is not None: graphics_engine.remove_window(buffer) + + +def pytest_configure(config): + """Initialize the failure collector.""" + config._github_summary_failures = [] + config._github_summary_counts = { + "passed": 0, + "failed": 0, + "skipped": 0, + "xfailed": 0, + } + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """Collect information about test outcomes.""" + outcome = yield + report = outcome.get_result() + + if report.when == "call": + counts = item.config._github_summary_counts + + if report.passed: + if hasattr(report, "wasxfail"): + # xpass - passed but was expected to fail (treat as passed for now) + counts["passed"] += 1 + else: + counts["passed"] += 1 + elif report.failed: + counts["failed"] += 1 + failure_info = { + "nodeid": report.nodeid, + "location": report.location, + "longrepr": str(report.longrepr) if report.longrepr else None, + "sections": report.sections, + "duration": report.duration, + } + item.config._github_summary_failures.append(failure_info) + elif report.skipped: + if hasattr(report, "wasxfail"): + counts["xfailed"] += 1 + else: + counts["skipped"] += 1 + + elif report.when == "setup" and report.skipped: + # Handle skip during setup (e.g., skipif, skip markers) + item.config._github_summary_counts["skipped"] += 1 + + +def pytest_sessionfinish(session, exitstatus): + """Write GitHub step summary if GITHUB_STEP_SUMMARY is set.""" + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not summary_path: + return + + failures = session.config._github_summary_failures + counts = session.config._github_summary_counts + + lines = [] + + # Build status parts for the summary line + status_parts = [] + if counts["failed"]: + status_parts.append(f"**{counts['failed']}** failed") + if counts["passed"]: + status_parts.append(f"**{counts['passed']}** passed") + if counts["skipped"]: + status_parts.append(f"**{counts['skipped']}** skipped") + if counts["xfailed"]: + status_parts.append(f"**{counts['xfailed']}** xfailed") + + total = sum(counts.values()) + status_line = ", ".join(status_parts) + f" (**{total}** total)\n" + + # Header with overall status + if counts["failed"] == 0: + lines.append(f"### :white_check_mark: All tests passed (Python {sys.version_info.major}.{sys.version_info.minor})\n\n") + else: + lines.append(f"### :x: Test failures (Python {sys.version_info.major}.{sys.version_info.minor})\n\n") + + lines.append(status_line) + lines.append("\n") + + # Each failure in a collapsible section + for failure in failures: + nodeid = failure["nodeid"] + duration = failure["duration"] + + lines.append(f"
\n{_escape_html(nodeid)} ({duration:.2f}s)\n") + + # Traceback / longrepr + if failure["longrepr"]: + lines.append("\n#### Traceback\n") + lines.append("```python\n") + lines.append(failure["longrepr"]) + if not failure["longrepr"].endswith("\n"): + lines.append("\n") + lines.append("```\n") + + # Captured output sections (stdout, stderr, log, etc.) + for section_name, section_content in failure["sections"]: + if section_content.strip(): + lines.append(f"\n#### {_escape_html(section_name)}\n") + lines.append("```\n") + lines.append(section_content) + if not section_content.endswith("\n"): + lines.append("\n") + lines.append("```\n") + + lines.append("\n
\n\n") + + with open(summary_path, "a", encoding="utf-8") as f: + f.writelines(lines) + + +def _escape_html(text: str) -> str: + """Escape HTML special characters for safe rendering in markdown.""" + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + )