Public Access
DEVX-50: refactor: remove JUnit reporting from devx
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 56s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 51s
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 6s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / release (push) Failing after 56s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / badges (push) Successful in 51s
This commit was merged in pull request #77.
This commit is contained in:
@@ -6,18 +6,13 @@ Wraps ``pytest`` with the same Gitea API polling mechanism used by
|
||||
reports failure, the current pytest subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
JUnit XML is generated via pytest's ``--junitxml`` flag (passed through
|
||||
to the pytest invocation).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.integration_guard \\
|
||||
--junit-output junit-results/runner-1.xml \\
|
||||
-- test_file1.py test_file2.py
|
||||
|
||||
# With pytest options
|
||||
python3 -m devx.ci.integration_guard \\
|
||||
--junit-output junit-results/runner-1.xml \\
|
||||
-- -x -v --tb=short test_file1.py
|
||||
|
||||
Environment variables:
|
||||
@@ -51,12 +46,7 @@ POLL_INTERVAL = 10
|
||||
|
||||
@click.command(context_settings={"ignore_unknown_options": True})
|
||||
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
||||
@click.option(
|
||||
"--junit-output",
|
||||
default=None,
|
||||
help="Path for JUnit XML output (passed to pytest as --junitxml).",
|
||||
)
|
||||
def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
|
||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
"""Run pytest with cross-runner failure detection."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
@@ -93,8 +83,6 @@ def cli(pytest_args: tuple[str, ...], junit_output: str | None) -> None:
|
||||
poller.start()
|
||||
|
||||
cmd = [sys.executable, "-m", "pytest"]
|
||||
if junit_output:
|
||||
cmd.extend(["--junitxml", junit_output])
|
||||
cmd.extend(pytest_args)
|
||||
|
||||
click.echo(f"Running: {' '.join(cmd)}")
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Merge multiple JUnit XML reports into a single report.
|
||||
|
||||
Used by CI workflows to consolidate JUnit XML files produced by
|
||||
parallel matrix runners into a single merged report for archival
|
||||
and dashboard consumption.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.merge_junit \\
|
||||
--pattern "junit-results/runner-*.xml" \\
|
||||
--output junit-merged.xml
|
||||
|
||||
Exit code is non-zero if any merged test suite reports failures,
|
||||
making this suitable as a CI gating step after matrix jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET # nosec B405
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def merge_files(pattern: str) -> tuple[ET.Element, int, int]:
|
||||
"""Merge JUnit XML files matching *pattern* into a single ``<testsuites>`` element.
|
||||
|
||||
Returns ``(merged_element, total_tests, total_failures)``.
|
||||
If no files match, returns an empty ``<testsuites>`` with zero counts.
|
||||
"""
|
||||
files = sorted(glob.glob(pattern))
|
||||
merged = ET.Element("testsuites")
|
||||
total_tests = 0
|
||||
total_failures = 0
|
||||
|
||||
for f in files:
|
||||
tree = ET.parse(f) # nosec B314
|
||||
suite = tree.getroot()
|
||||
# Handle both <testsuites> (wrapper) and <testsuite> (single) roots
|
||||
if suite.tag == "testsuites":
|
||||
for child in suite:
|
||||
merged.append(child)
|
||||
total_tests += int(child.get("tests", 0))
|
||||
total_failures += int(child.get("failures", 0))
|
||||
else:
|
||||
merged.append(suite)
|
||||
total_tests += int(suite.get("tests", 0))
|
||||
total_failures += int(suite.get("failures", 0))
|
||||
|
||||
merged.set("tests", str(total_tests))
|
||||
merged.set("failures", str(total_failures))
|
||||
return merged, total_tests, total_failures
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--pattern",
|
||||
default="junit-results/runner-*.xml",
|
||||
show_default=True,
|
||||
help="Glob pattern for input JUnit XML files.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
default="junit-merged.xml",
|
||||
show_default=True,
|
||||
help="Output path for the merged JUnit XML file.",
|
||||
)
|
||||
def main(pattern: str, output: str) -> None:
|
||||
merged, total_tests, total_failures = merge_files(pattern)
|
||||
|
||||
if total_tests == 0:
|
||||
click.echo(_("No JUnit reports found matching {pattern} — skipping merge.", pattern=pattern))
|
||||
return
|
||||
|
||||
ET.indent(merged)
|
||||
tree = ET.ElementTree(merged)
|
||||
tree.write(output, encoding="UTF-8", xml_declaration=True)
|
||||
click.echo(
|
||||
_(
|
||||
"Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
count=len(glob.glob(pattern)),
|
||||
tests=total_tests,
|
||||
failures=total_failures,
|
||||
output=output,
|
||||
)
|
||||
)
|
||||
|
||||
if total_failures > 0:
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
+1
-8
@@ -158,17 +158,10 @@ def ci_distribute_files(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.ci.distribute_files", list(args))
|
||||
|
||||
|
||||
@ci.command("merge-junit")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_merge_junit(args: tuple[str, ...]) -> None:
|
||||
"""Merge multiple JUnit XML reports into a single report."""
|
||||
_run_module("devx.ci.merge_junit", list(args))
|
||||
|
||||
|
||||
@ci.command("integration-guard")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_integration_guard(args: tuple[str, ...]) -> None:
|
||||
"""Run pytest with cross-runner failure detection and JUnit output."""
|
||||
"""Run pytest with cross-runner failure detection."""
|
||||
_run_module("devx.ci.integration_guard", list(args))
|
||||
|
||||
|
||||
|
||||
@@ -13,17 +13,12 @@ A background thread polls the Gitea API. If any other molecule matrix runner
|
||||
reports failure, the current molecule subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
JUnit XML is generated when ``--junit-output`` is provided, recording each
|
||||
pair as a testcase with pass/fail status and elapsed time.
|
||||
|
||||
Usage::
|
||||
|
||||
# Single-role (grm-style)
|
||||
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
|
||||
# Multi-role (infra-style)
|
||||
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
|
||||
# With JUnit output
|
||||
python3 -m devx.molecule.molecule_ci_guard --junit-output junit-results/runner-1.xml pair1 pair2 ...
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
@@ -43,7 +38,6 @@ import subprocess # nosec B404
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import xml.etree.ElementTree as ET # nosec B405
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
@@ -158,53 +152,15 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
|
||||
return repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
|
||||
def write_junit_report(
|
||||
output_path: str,
|
||||
testcases: list[dict],
|
||||
runner_index: int,
|
||||
) -> None:
|
||||
"""Write a JUnit XML report from collected test case results.
|
||||
|
||||
Each testcase dict has: role, scenario, time (float), passed (bool), error (str|None).
|
||||
"""
|
||||
suite = ET.Element(
|
||||
"testsuite",
|
||||
name=f"molecule-runner-{runner_index}",
|
||||
tests=str(len(testcases)),
|
||||
failures=str(sum(1 for tc in testcases if not tc["passed"])),
|
||||
)
|
||||
for tc in testcases:
|
||||
classname = tc["role"] if tc["role"] else "molecule"
|
||||
elem = ET.SubElement(
|
||||
suite,
|
||||
"testcase",
|
||||
classname=classname,
|
||||
name=tc["scenario"],
|
||||
time=f"{tc['time']:.1f}",
|
||||
)
|
||||
if not tc["passed"]:
|
||||
fail = ET.SubElement(elem, "failure")
|
||||
fail.text = tc.get("error") or "molecule test failed"
|
||||
tree = ET.ElementTree(suite)
|
||||
ET.indent(tree)
|
||||
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
tree.write(output_path, encoding="UTF-8", xml_declaration=True)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pairs", nargs=-1, required=True)
|
||||
@click.option(
|
||||
"--junit-output",
|
||||
default=None,
|
||||
help="Path to write JUnit XML report (e.g. junit-results/runner-1.xml).",
|
||||
)
|
||||
@click.option(
|
||||
"--roles-root",
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
|
||||
)
|
||||
def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | None) -> None:
|
||||
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
@@ -249,8 +205,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
)
|
||||
poller.start()
|
||||
|
||||
testcases: list[dict] = []
|
||||
|
||||
try:
|
||||
for pair in pairs:
|
||||
if failed_event.is_set():
|
||||
@@ -263,7 +217,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
env = build_env_for_pair(pair, base_env)
|
||||
cwd = resolve_role_dir(role, roles_root, repo_root)
|
||||
|
||||
start = time.time()
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
@@ -282,18 +235,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
process.wait()
|
||||
elapsed = time.time() - start
|
||||
testcases.append(
|
||||
{
|
||||
"role": role,
|
||||
"scenario": scenario,
|
||||
"time": elapsed,
|
||||
"passed": False,
|
||||
"error": "Cancelled — another runner failed",
|
||||
}
|
||||
)
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
sys.exit(1)
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
@@ -303,23 +244,9 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
sys.exit(1)
|
||||
|
||||
rc = process.returncode
|
||||
elapsed = time.time() - start
|
||||
passed = rc == 0
|
||||
|
||||
testcases.append(
|
||||
{
|
||||
"role": role,
|
||||
"scenario": scenario,
|
||||
"time": elapsed,
|
||||
"passed": passed,
|
||||
"error": f"Exit code: {rc}" if not passed else None,
|
||||
}
|
||||
)
|
||||
|
||||
if rc != 0:
|
||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
@@ -336,8 +263,6 @@ def cli(pairs: tuple[str, ...], junit_output: str | None, roles_root: Path | Non
|
||||
)
|
||||
|
||||
click.echo(_("All molecule tests passed."))
|
||||
if junit_output:
|
||||
write_junit_report(junit_output, testcases, current_index)
|
||||
finally:
|
||||
stop_event.set()
|
||||
|
||||
|
||||
@@ -727,14 +727,6 @@
|
||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Merged {count} reports: {tests} tests, {failures} failures → {output}": {
|
||||
"bg": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"de": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"en": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"pl": "Scalono {count} raportów: {tests} testów, {failures} niepowodzeń → {output}",
|
||||
"ru": "Merged {count} reports: {tests} tests, {failures} failures → {output}",
|
||||
"zh": "Merged {count} reports: {tests} tests, {failures} failures → {output}"
|
||||
},
|
||||
"Module {mod} has no main() function": {
|
||||
"bg": "Модул {mod} няма функция main()",
|
||||
"de": "Modul {mod} hat keine main()-Funktion",
|
||||
@@ -783,14 +775,6 @@
|
||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No JUnit reports found matching {pattern} — skipping merge.": {
|
||||
"bg": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"de": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"en": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"pl": "Nie znaleziono raportów JUnit pasujących do {pattern} — pomijanie scalania.",
|
||||
"ru": "No JUnit reports found matching {pattern} — skipping merge.",
|
||||
"zh": "No JUnit reports found matching {pattern} — skipping merge."
|
||||
},
|
||||
"No changes between {base} and {head}.": {
|
||||
"bg": "No changes between {base} and {head}.",
|
||||
"de": "No changes between {base} and {head}.",
|
||||
|
||||
Reference in New Issue
Block a user