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 "")