diff --git a/AGENTS.md b/AGENTS.md
index d3851b5..7d4dbd9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,9 +66,8 @@ src/devx/
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
-│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
-│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit output
+│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
├── tools/ # Developer tooling modules (run locally or by CI)
@@ -81,7 +80,7 @@ src/devx/
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
- ├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast + JUnit output (--roles-root, --junit-output)
+ ├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
├── molecule_all.py # Run all molecule scenarios locally
└── platforms.py # Supported molecule platforms
```
diff --git a/README.md b/README.md
index ae46b77..9acb020 100644
--- a/README.md
+++ b/README.md
@@ -175,11 +175,8 @@ python -m devx.ci.discover_runners --owner oblachno-oss --repo devx --indices
python -m devx.ci.distribute_files --pattern "tests/integration/test_*.py" \
--runner-index 1 --max-runners 3 --github-env
-# Merge JUnit XML reports from parallel runners
-python -m devx.ci.merge_junit --pattern "junit-results/runner-*.xml" --output junit-merged.xml
-
-# Run pytest with cross-runner fail-fast and JUnit output
-python -m devx.ci.integration_guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
+# Run pytest with cross-runner fail-fast
+python -m devx.ci.integration_guard -- test_a.py test_b.py
```
### Developer tools
@@ -227,7 +224,7 @@ python -m devx.molecule.distribute_molecule --list # list all scena
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
# Run molecule tests with cross-runner fail-fast
-python -m devx.molecule.molecule_ci_guard --junit-output junit.xml pair1 pair2
+python -m devx.molecule.molecule_ci_guard pair1 pair2
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
# Run all molecule scenarios locally (sequential)
@@ -274,8 +271,7 @@ devx --version
| `devx ci discover-runners` | Discover available Gitea Actions runners |
| `devx ci distribute-files` | Distribute files across parallel runners (round-robin) |
| `devx ci doc-coverage` | Check documentation coverage for CLI commands and modules |
-| `devx ci integration-guard` | Run pytest with cross-runner fail-fast and JUnit output |
-| `devx ci merge-junit` | Merge JUnit XML reports from parallel runners |
+| `devx ci integration-guard` | Run pytest with cross-runner fail-fast |
| `devx ci notify-failure` | Create a Gitea issue when a CI workflow fails |
| `devx ci post-merge` | Update Vikunja task after a merge to master |
| `devx ci pr-review` | Run automated PR review |
diff --git a/docs/index.md b/docs/index.md
index ae0b97f..b0d776c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -100,7 +100,7 @@ devx is a self-contained Python package under `src/devx/`:
- **CI automation** (`devx.ci`) — release, publish, auto_merge, pr_review,
classify_changes, sync_wiki, push_badges, check_translations, doc_coverage,
validate_commit_msg, detect_release_commit, notify_failure, post_merge,
- discover_runners, distribute_files, merge_junit, integration_guard
+ discover_runners, distribute_files, integration_guard
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
configure_repo, generate_badges, generate_cliff_config, install_checkmake
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md
index 9a9e686..a9a57d0 100644
--- a/docs/tech/architecture.md
+++ b/docs/tech/architecture.md
@@ -31,9 +31,8 @@ src/devx/
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges
│ ├── notify_failure.py # Create Gitea issues on CI failures
-│ ├── merge_junit.py # Merge JUnit XML reports from parallel runners
│ ├── distribute_files.py # Distribute files across parallel runners
-│ ├── integration_guard.py # Run pytest with cross-runner fail-fast + JUnit
+│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── discover_runners.py # Dynamic Gitea runner discovery
│ ├── check_translations.py # Translation completeness check
│ └── doc_coverage.py # Documentation coverage check
@@ -296,18 +295,11 @@ Distributes files matching a glob pattern across N parallel runners
(round-robin). Writes the assigned file list for the current runner to
`$GITHUB_ENV`. Used for splitting test suites across CI runners.
-### `merge_junit.py`
-
-Merges JUnit XML reports from parallel matrix runners into a single
-consolidated report. Exit code is non-zero if any merged suite reports
-failures, making it suitable as a CI gating step.
-
### `integration_guard.py`
Runs pytest with the same cross-runner failure detection mechanism used by
`molecule_ci_guard`. If any other integration-tests matrix runner reports
failure, the current pytest subprocess is killed and this runner exits early.
-Generates JUnit XML via pytest's `--junitxml` flag.
## Developer tools (`devx.tools`)
@@ -382,9 +374,8 @@ platforms.
Runs molecule tests sequentially while polling the Gitea API for other runner
failures. If any other molecule matrix runner reports failure, the current
-molecule subprocess is killed and this runner exits early. Generates JUnit
-XML when `--junit-output` is provided. Supports both single-role (4-part) and
-multi-role (5-part) pair encoding.
+molecule subprocess is killed and this runner exits early. Supports both
+single-role (4-part) and multi-role (5-part) pair encoding.
### `molecule_all.py`
diff --git a/docs/tech/ci-cd-workflow.md b/docs/tech/ci-cd-workflow.md
index a41cce7..96a2d47 100644
--- a/docs/tech/ci-cd-workflow.md
+++ b/docs/tech/ci-cd-workflow.md
@@ -447,12 +447,10 @@ python -m devx.molecule.distribute_molecule --list-platforms
### `molecule_ci_guard.py`
Runs molecule tests sequentially while polling the Gitea API for other runner
-failures. Aborts early if another runner fails the same job. Generates JUnit
-XML when `--junit-output` is provided.
+failures. Aborts early if another runner fails the same job.
```bash
-python -m devx.molecule.molecule_ci_guard [--roles-root
] \
- [--junit-output ] pair1 pair2 ...
+python -m devx.molecule.molecule_ci_guard [--roles-root ] pair1 pair2 ...
```
### `validate_commit_msg.py`
@@ -503,16 +501,6 @@ python -m devx.ci.distribute_files --pattern --runner-index \
--max-runners [--github-env] [--skip-if-excess]
```
-### `merge_junit.py`
-
-Merges JUnit XML reports from parallel matrix runners into a single
-consolidated report. Exit code is non-zero if any merged suite reports
-failures.
-
-```bash
-python -m devx.ci.merge_junit --pattern --output
-```
-
### `integration_guard.py`
Runs pytest with cross-runner failure detection. If any other
@@ -520,7 +508,7 @@ integration-tests matrix runner reports failure, the current pytest
subprocess is killed and this runner exits early.
```bash
-python -m devx.ci.integration_guard --junit-output --
+python -m devx.ci.integration_guard --
```
## Release process summary
diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md
index a6b4570..16351e2 100644
--- a/docs/user/cli-commands.md
+++ b/docs/user/cli-commands.md
@@ -137,13 +137,13 @@ Options:
### `devx ci integration-guard`
-Run pytest with cross-runner failure detection and JUnit XML output. If any
+Run pytest with cross-runner failure detection. If any
other integration-tests matrix runner reports failure, the current pytest
subprocess is killed and this runner exits early with code 1.
```bash
-devx ci integration-guard --junit-output junit-results/runner-1.xml -- test_a.py test_b.py
-devx ci integration-guard --junit-output junit-results/runner-1.xml -- -x -v --tb=short test_a.py
+devx ci integration-guard -- test_a.py test_b.py
+devx ci integration-guard -- -x -v --tb=short test_a.py
```
Environment variables:
@@ -154,16 +154,6 @@ Environment variables:
- `MATRIX_INDEX` — current matrix index (runner-index)
- `GITEA_REPOSITORY` — repository in `owner/repo` format
-### `devx ci merge-junit`
-
-Merge multiple JUnit XML reports from parallel runners into a single
-consolidated report. Exit code is non-zero if any merged test suite reports
-failures, making it suitable as a CI gating step after matrix jobs.
-
-```bash
-devx ci merge-junit --pattern "junit-results/runner-*.xml" --output junit-merged.xml
-```
-
### `devx ci notify-failure`
Create a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
@@ -461,7 +451,6 @@ current molecule subprocess is killed and this runner exits early with code 1.
```bash
devx molecule guard pair1 pair2 pair3
devx molecule guard --roles-root ansible/roles pair1 pair2
-devx molecule guard --junit-output junit-results/runner-1.xml pair1 pair2
```
Each pair is encoded as:
@@ -470,7 +459,6 @@ Each pair is encoded as:
Options:
- `--roles-root ` — roles root directory for multi-role repos
-- `--junit-output ` — generate JUnit XML report
Environment variables:
- `GITEA_URL` — base URL of the Gitea instance
diff --git a/src/devx/ci/integration_guard.py b/src/devx/ci/integration_guard.py
index ab26dec..457281a 100644
--- a/src/devx/ci/integration_guard.py
+++ b/src/devx/ci/integration_guard.py
@@ -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)}")
diff --git a/src/devx/ci/merge_junit.py b/src/devx/ci/merge_junit.py
deleted file mode 100644
index 8e26b81..0000000
--- a/src/devx/ci/merge_junit.py
+++ /dev/null
@@ -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 ```` element.
-
- Returns ``(merged_element, total_tests, total_failures)``.
- If no files match, returns an empty ```` 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 (wrapper) and (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()
diff --git a/src/devx/cli.py b/src/devx/cli.py
index 7aea6aa..2e602ad 100644
--- a/src/devx/cli.py
+++ b/src/devx/cli.py
@@ -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))
diff --git a/src/devx/molecule/molecule_ci_guard.py b/src/devx/molecule/molecule_ci_guard.py
index e7f9a93..6bfee34 100644
--- a/src/devx/molecule/molecule_ci_guard.py
+++ b/src/devx/molecule/molecule_ci_guard.py
@@ -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()
diff --git a/src/devx/translations.json b/src/devx/translations.json
index 6666e94..240bfad 100644
--- a/src/devx/translations.json
+++ b/src/devx/translations.json
@@ -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}.",
diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py
index 0614d49..46f0f37 100644
--- a/tests/unit/test_cli.py
+++ b/tests/unit/test_cli.py
@@ -233,13 +233,6 @@ class TestNewCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.distribute_files", ["--pattern", "*.py"])
- @patch("devx.cli._run_module")
- def test_ci_merge_junit(self, mock_run: MagicMock) -> None:
- runner = CliRunner()
- result = runner.invoke(cli, ["ci", "merge-junit", "--", "--output", "merged.xml"])
- assert result.exit_code == 0
- mock_run.assert_called_once_with("devx.ci.merge_junit", ["--output", "merged.xml"])
-
@patch("devx.cli._run_module")
def test_ci_integration_guard(self, mock_run: MagicMock) -> None:
runner = CliRunner()
diff --git a/tests/unit/test_integration_guard.py b/tests/unit/test_integration_guard.py
index b774b2c..449bef5 100644
--- a/tests/unit/test_integration_guard.py
+++ b/tests/unit/test_integration_guard.py
@@ -43,26 +43,6 @@ class TestCli:
assert result.exit_code == 1
assert "failed" in result.output
- def test_junit_output_passed_to_pytest(self) -> None:
- with (
- patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
- patch("time.sleep"),
- ):
- proc = MagicMock()
- proc.poll.return_value = 0
- proc.returncode = 0
- mock_popen.return_value = proc
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- ["--junit-output", "junit-results/runner-1.xml", "--", "test_foo.py"],
- )
- assert result.exit_code == 0
- call_args = mock_popen.call_args[0][0]
- assert "--junitxml" in call_args
- assert "junit-results/runner-1.xml" in call_args
-
def test_pytest_args_passed_through(self) -> None:
with (
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
diff --git a/tests/unit/test_merge_junit.py b/tests/unit/test_merge_junit.py
deleted file mode 100644
index e146285..0000000
--- a/tests/unit/test_merge_junit.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""Unit tests for devx.ci.merge_junit."""
-
-from pathlib import Path
-from xml.etree import ElementTree as ET
-
-import pytest
-from click.testing import CliRunner
-
-from devx.ci.merge_junit import main, merge_files
-
-
-def _write_suite(path: Path, name: str, tests: int, failures: int) -> None:
- suite = ET.Element("testsuite", name=name, tests=str(tests), failures=str(failures))
- for i in range(tests):
- tc = ET.SubElement(suite, "testcase", classname="cls", name=f"test{i}", time="0.1")
- if i < failures:
- ET.SubElement(tc, "failure", message="fail")
- tree = ET.ElementTree(suite)
- tree.write(path, encoding="UTF-8", xml_declaration=True)
-
-
-class TestMergeFiles:
- def test_merges_multiple_suites(self, tmp_path: Path) -> None:
- _write_suite(tmp_path / "runner-1.xml", "r1", tests=3, failures=1)
- _write_suite(tmp_path / "runner-2.xml", "r2", tests=2, failures=0)
- merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
- assert total_tests == 5
- assert total_failures == 1
- assert merged.tag == "testsuites"
- assert len(merged) == 2
-
- def test_no_files_returns_empty(self, tmp_path: Path) -> None:
- merged, total_tests, total_failures = merge_files(str(tmp_path / "nonexistent-*.xml"))
- assert total_tests == 0
- assert total_failures == 0
- assert merged.tag == "testsuites"
- assert len(merged) == 0
-
- def test_handles_testsuites_wrapper_root(self, tmp_path: Path) -> None:
- wrapper = ET.Element("testsuites")
- suite = ET.SubElement(wrapper, "testsuite", name="r1", tests="4", failures="2")
- ET.SubElement(suite, "testcase", classname="c", name="t", time="0.1")
- tree = ET.ElementTree(wrapper)
- tree.write(tmp_path / "runner-1.xml", encoding="UTF-8", xml_declaration=True)
- merged, total_tests, total_failures = merge_files(str(tmp_path / "runner-*.xml"))
- assert total_tests == 4
- assert total_failures == 2
-
-
-class TestCli:
- def test_writes_merged_file(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- _write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=0)
- _write_suite(tmp_path / "runner-2.xml", "r2", tests=3, failures=0)
- out = tmp_path / "merged.xml"
- runner = CliRunner()
- result = runner.invoke(
- main,
- ["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
- )
- assert result.exit_code == 0
- assert out.exists()
- tree = ET.parse(out)
- root = tree.getroot()
- assert root.get("tests") == "5"
- assert root.get("failures") == "0"
-
- def test_exits_nonzero_on_failures(self, tmp_path: Path) -> None:
- _write_suite(tmp_path / "runner-1.xml", "r1", tests=2, failures=1)
- out = tmp_path / "merged.xml"
- runner = CliRunner()
- result = runner.invoke(
- main,
- ["--pattern", str(tmp_path / "runner-*.xml"), "--output", str(out)],
- )
- assert result.exit_code != 0
- assert "failures" in result.output
-
- def test_no_files_exits_zero(self, tmp_path: Path) -> None:
- runner = CliRunner()
- result = runner.invoke(
- main,
- ["--pattern", str(tmp_path / "nonexistent-*.xml"), "--output", str(tmp_path / "out.xml")],
- )
- assert result.exit_code == 0
- assert "No JUnit" in result.output or "skipping" in result.output
-
-
-def test_main_module_block() -> None:
- import devx.ci.merge_junit as mod
-
- assert hasattr(mod, "main")
diff --git a/tests/unit/test_molecule_ci_guard.py b/tests/unit/test_molecule_ci_guard.py
index 542a435..2735651 100644
--- a/tests/unit/test_molecule_ci_guard.py
+++ b/tests/unit/test_molecule_ci_guard.py
@@ -5,7 +5,6 @@ from __future__ import annotations
import os
import subprocess # nosec B404
import time
-import xml.etree.ElementTree as ET
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -22,7 +21,6 @@ from devx.molecule.molecule_ci_guard import (
parse_pair,
poll_for_other_failures,
resolve_role_dir,
- write_junit_report,
)
@@ -504,40 +502,6 @@ class TestResolveRoleDir:
assert result == tmp_path / "ansible" / "roles" / "gitea-runner"
-class TestWriteJunitReport:
- def test_writes_report_with_passing_tests(self, tmp_path: Path) -> None:
- output = str(tmp_path / "junit-results" / "runner-1.xml")
- testcases = [
- {"role": "gitea-runner", "scenario": "default", "time": 5.2, "passed": True, "error": None},
- {"role": "docker-base", "scenario": "lifecycle", "time": 3.1, "passed": True, "error": None},
- ]
- write_junit_report(output, testcases, 1)
- tree = ET.parse(output)
- root = tree.getroot()
- assert root.get("tests") == "2"
- assert root.get("failures") == "0"
- assert len(root) == 2
-
- def test_writes_report_with_failures(self, tmp_path: Path) -> None:
- output = str(tmp_path / "runner-2.xml")
- testcases = [
- {"role": "", "scenario": "default", "time": 1.0, "passed": False, "error": "Exit code: 1"},
- ]
- write_junit_report(output, testcases, 2)
- tree = ET.parse(output)
- root = tree.getroot()
- assert root.get("tests") == "1"
- assert root.get("failures") == "1"
- failure = root[0][0]
- assert failure.tag == "failure"
- assert failure.text == "Exit code: 1"
-
- def test_creates_parent_directory(self, tmp_path: Path) -> None:
- output = str(tmp_path / "deep" / "nested" / "dir" / "runner.xml")
- write_junit_report(output, [], 0)
- assert Path(output).exists()
-
-
class TestCliMultiRole:
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
from click.testing import CliRunner
@@ -563,133 +527,3 @@ class TestCliMultiRole:
)
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
-
- def test_junit_output_written(self, tmp_path: Path) -> None:
- from click.testing import CliRunner
-
- roles_root = tmp_path / "ansible" / "roles"
- (roles_root / "gitea-runner").mkdir(parents=True)
- junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
-
- with (
- patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
- patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
- patch("time.sleep"),
- ):
- proc = MagicMock()
- proc.poll.return_value = 0
- proc.returncode = 0
- mock_popen.return_value = proc
- mock_run.return_value = MagicMock(returncode=0)
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--roles-root",
- str(roles_root),
- "--junit-output",
- junit_path,
- "gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
- ],
- )
- assert result.exit_code == 0
- assert Path(junit_path).exists()
-
- def test_junit_output_on_failure(self, tmp_path: Path) -> None:
- from click.testing import CliRunner
-
- roles_root = tmp_path / "ansible" / "roles"
- (roles_root / "gitea-runner").mkdir(parents=True)
- junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
-
- with (
- patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
- patch("time.sleep"),
- ):
- proc = MagicMock()
- proc.poll.return_value = 1
- proc.returncode = 1
- mock_popen.return_value = proc
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--roles-root",
- str(roles_root),
- "--junit-output",
- junit_path,
- "gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
- ],
- )
- assert result.exit_code == 1
- assert Path(junit_path).exists()
- tree = ET.parse(junit_path)
- assert tree.getroot().get("failures") == "1"
-
- def test_junit_output_on_cancellation(self, tmp_path: Path) -> None:
- """JUnit report is written when a runner is cancelled by another runner's failure."""
- from click.testing import CliRunner
-
- real_sleep = time.sleep
- roles_root = tmp_path / "ansible" / "roles"
- (roles_root / "gitea-runner").mkdir(parents=True)
- junit_path = str(tmp_path / "junit-results" / "runner-1.xml")
- call_count = [0]
-
- def get_jobs_side_effect(*args, **kwargs):
- call_count[0] += 1
- if call_count[0] < 2:
- return [{"name": "molecule-tests (1)", "conclusion": "running"}]
- return [
- {"name": "molecule-tests (0)", "conclusion": "running"},
- {"name": "molecule-tests (1)", "conclusion": "failure"},
- ]
-
- with (
- patch.dict(
- os.environ,
- {
- "GITEA_URL": "https://gitea.example",
- "REPO_TOKEN": "token",
- "RUN_ID": "123",
- "JOB_NAME": "molecule-tests",
- "MATRIX_INDEX": "0",
- "GITEA_REPOSITORY": "oblachno-oss/infra",
- "PATH": os.environ.get("PATH", ""),
- },
- clear=True,
- ),
- patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
- patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
- patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
- patch("os.killpg"),
- patch("os.getpgid") as mock_getpgid,
- patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
- ):
- mock_getpgid.return_value = 123
- proc = MagicMock()
- proc.poll.return_value = None
- proc.wait.return_value = 0
- mock_popen.return_value = proc
-
- runner = CliRunner()
- result = runner.invoke(
- cli,
- [
- "--roles-root",
- str(roles_root),
- "--junit-output",
- junit_path,
- "gitea-runner|default|ubuntu-2204|ubuntu:22.04|",
- ],
- )
- assert result.exit_code == 1
- assert Path(junit_path).exists()
- tree = ET.parse(junit_path)
- root = tree.getroot()
- assert root.get("failures") == "1"
- # The failure message should mention cancellation
- failure = root[0][0]
- assert "Cancelled" in (failure.text or "")