From 8dc7a4fb99a145c0f1cbe7bda36cca757446a007 Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 26 May 2026 21:18:20 +0200 Subject: [PATCH] Add coverage gap tests for command functionalities - Implement tests for disk alert JSON output to include a message field when below threshold. - Add tests for runner status when Gitea is online but no webhook URL is provided. - Introduce tests for job report details to ensure no error line appears when there are no failure events. - Add tests to skip empty JSONL files in job report listing. --- tests/python/test_commands_artifacts.py | 231 ++++++ tests/python/test_commands_build.py | 393 +++++++++- tests/python/test_commands_job.py | 931 ++++++++++++++++++++++++ tests/python/test_commands_monitor.py | 46 ++ tests/python/test_commands_report.py | 34 + 5 files changed, 1634 insertions(+), 1 deletion(-) diff --git a/tests/python/test_commands_artifacts.py b/tests/python/test_commands_artifacts.py index bdef687..1ece0c9 100644 --- a/tests/python/test_commands_artifacts.py +++ b/tests/python/test_commands_artifacts.py @@ -322,3 +322,234 @@ def test_collect_linux_no_files_raises( ) assert result.exit_code != 0 assert "no files were transferred" in result.output + + +# ── NEW: coverage gap tests ─────────────────────────────────────────────────── + + +from ci_orchestrator.transport.errors import TransportError # noqa: E402 + + +def test_collect_windows_include_logs_fetch_succeeds( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Line 187 (191): log fetch succeeds → file deposited, no error message.""" + _patch(monkeypatch) + payload = _zip_payload(tmp_path) + + class _LogFetchOk(_win_transport_with_zip(payload)): # type: ignore[misc] + def __init__(self, *a: Any, **kw: Any) -> None: + super().__init__(*a, **kw) + # The log-listing command returns one log file path. + self.run_overrides["C:\\CI\\build"] = _FakeResult( + stdout="C:\\CI\\build\\build.log\r\n" + ) + + def fetch(self, remote_path: str, local_path: str) -> None: + self.fetched.append((remote_path, str(local_path))) + # Write something for the log file fetch too. + Path(local_path).write_bytes(payload if local_path.endswith(".zip") else b"log content") + + monkeypatch.setattr(artifacts_module, "WinRmTransport", _LogFetchOk) + out_dir = tmp_path / "out" + result = CliRunner().invoke( + cli, + [ + "artifacts", "collect", + "--ip-address", "10.0.0.7", + "--guest-artifact-path", "C:\\CI\\output", + "--host-artifact-dir", str(out_dir), + "--include-logs", + ], + ) + assert result.exit_code == 0, result.output + # fetch was called for both the zip and the log + fetched_remotes = [r for r, _ in _FakeTransport.instances[0].fetched] + assert "C:\\CI\\build\\build.log" in fetched_remotes + assert "fetching log" in result.output + + +def test_collect_windows_include_logs_fetch_fails( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Lines 192-193: log fetch raises TransportError → error message printed, continues.""" + _patch(monkeypatch) + payload = _zip_payload(tmp_path) + + class _LogFetchFail(_win_transport_with_zip(payload)): # type: ignore[misc] + def __init__(self, *a: Any, **kw: Any) -> None: + super().__init__(*a, **kw) + self.run_overrides["C:\\CI\\build"] = _FakeResult( + stdout="C:\\CI\\build\\fail.log\r\n" + ) + + def fetch(self, remote_path: str, local_path: str) -> None: + if remote_path.endswith(".log"): + raise TransportError("log fetch boom") + # Normal zip fetch + self.fetched.append((remote_path, str(local_path))) + Path(local_path).write_bytes(payload) + + monkeypatch.setattr(artifacts_module, "WinRmTransport", _LogFetchFail) + out_dir = tmp_path / "out" + result = CliRunner().invoke( + cli, + [ + "artifacts", "collect", + "--ip-address", "10.0.0.7", + "--guest-artifact-path", "C:\\CI\\output", + "--host-artifact-dir", str(out_dir), + "--include-logs", + ], + ) + # Should succeed overall (error logged but not fatal) + assert result.exit_code == 0, result.output + assert "log fetch failed" in result.output + + +def test_collect_windows_include_logs_no_artifacts( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Line 199: include_logs=True, no artifacts extracted → ClickException.""" + _patch(monkeypatch) + + # Transport that returns a real-looking zip but empty after extraction, + # and returns log listing. + empty_zip = tmp_path / "empty.zip" + import zipfile as _zf + with _zf.ZipFile(empty_zip, "w"): + pass + empty_zip_bytes = empty_zip.read_bytes() + + class _NoArtifacts(_FakeTransport): + def __init__(self, *a: Any, **kw: Any) -> None: + super().__init__(*a, **kw) + # no log lines returned + self.run_overrides["C:\\CI\\build"] = _FakeResult(stdout="") + + def fetch(self, remote_path: str, local_path: str) -> None: + self.fetched.append((remote_path, str(local_path))) + Path(local_path).write_bytes(empty_zip_bytes) + + monkeypatch.setattr(artifacts_module, "WinRmTransport", _NoArtifacts) + result = CliRunner().invoke( + cli, + [ + "artifacts", "collect", + "--ip-address", "10.0.0.7", + "--guest-artifact-path", "C:\\CI\\output", + "--host-artifact-dir", str(tmp_path / "out"), + "--include-logs", + ], + ) + assert result.exit_code != 0 + assert "no artifacts after collect" in result.output + + +def test_collect_linux_key_from_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Line 261: --guest-os linux with no --ssh-key-path but config.ssh_key_path set.""" + _patch(monkeypatch) + + # Build a real tarball for the fake transport to return. + payload_dir = tmp_path / "payload" + payload_dir.mkdir() + (payload_dir / "out.txt").write_text("hi", encoding="utf-8") + tar_local = tmp_path / "src.tar.gz" + with tarfile.open(tar_local, "w:gz") as tf: + for f in payload_dir.iterdir(): + tf.add(f, arcname=f.name) + payload_bytes = tar_local.read_bytes() + + captured_key: list[Any] = [] + + class _LinuxCapture(_FakeTransport): + def __init__(self, *a: Any, **kw: Any) -> None: + super().__init__(*a, **kw) + captured_key.append(kw.get("key_path")) + + def fetch(self, remote_path: str, local_path: str) -> None: + self.fetched.append((remote_path, str(local_path))) + Path(local_path).write_bytes(payload_bytes) + + monkeypatch.setattr(artifacts_module, "SshTransport", _LinuxCapture) + + # Patch load_config to return a config that has ssh_key_path set. + from ci_orchestrator.config import BackendConfig, Config, Paths + + fake_key = tmp_path / "ci_linux" + _test_paths = Paths( + root=tmp_path, templates=tmp_path, build_vms=tmp_path, + artifacts=tmp_path, keys=tmp_path, + ) + _test_cfg = Config(paths=_test_paths, backend=BackendConfig(), ssh_key_path=fake_key) + monkeypatch.setattr(artifacts_module, "load_config", lambda: _test_cfg) + + result = CliRunner().invoke( + cli, + [ + "artifacts", "collect", + "--ip-address", "10.0.0.5", + "--guest-os", "linux", + "--guest-artifact-path", "/opt/ci/output", + "--host-artifact-dir", str(tmp_path / "out"), + # no --ssh-key-path → should come from config + ], + ) + assert result.exit_code == 0, result.output + assert captured_key and captured_key[0] == str(fake_key) + + +def test_collect_transport_error_raises_click_exception( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Line 280: TransportError during collect → ClickException.""" + _patch(monkeypatch) + + class _Boom(_FakeTransport): + def __enter__(self) -> _Boom: + raise TransportError("connect failed") + + monkeypatch.setattr(artifacts_module, "WinRmTransport", _Boom) + result = CliRunner().invoke( + cli, + [ + "artifacts", "collect", + "--ip-address", "10.0.0.7", + "--guest-artifact-path", "C:\\CI\\output", + "--host-artifact-dir", str(tmp_path / "out"), + ], + ) + assert result.exit_code != 0 + assert "connect failed" in result.output + + +def test_collect_windows_log_listing_skips_blank_lines( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Line 187 (continue): listing stdout has blank lines → skipped silently.""" + _patch(monkeypatch) + payload = _zip_payload(tmp_path) + + class _BlankLines(_win_transport_with_zip(payload)): # type: ignore[misc] + def __init__(self, *a: Any, **kw: Any) -> None: + super().__init__(*a, **kw) + # Return listing with leading/trailing blank lines only — no real log paths. + self.run_overrides["C:\\CI\\build"] = _FakeResult(stdout="\r\n \r\n") + + monkeypatch.setattr(artifacts_module, "WinRmTransport", _BlankLines) + out_dir = tmp_path / "out" + result = CliRunner().invoke( + cli, + [ + "artifacts", "collect", + "--ip-address", "10.0.0.7", + "--guest-artifact-path", "C:\\CI\\output", + "--host-artifact-dir", str(out_dir), + "--include-logs", + ], + ) + assert result.exit_code == 0, result.output + # No "fetching log" output — all listing lines were blank and skipped. + assert "fetching log" not in result.output diff --git a/tests/python/test_commands_build.py b/tests/python/test_commands_build.py index 15a4b6d..3c1edf8 100644 --- a/tests/python/test_commands_build.py +++ b/tests/python/test_commands_build.py @@ -10,8 +10,9 @@ from click.testing import CliRunner import ci_orchestrator.commands.build as build_module from ci_orchestrator.__main__ import cli +from ci_orchestrator.config import BackendConfig, Config, Paths from ci_orchestrator.credentials import Credential -from ci_orchestrator.transport.errors import TransportCommandError +from ci_orchestrator.transport.errors import TransportCommandError, TransportError class _FakeResult: @@ -370,3 +371,393 @@ def test_build_run_windows_host_source_dir( # Staging copies the raw source into the collect dir (no zip-in-zip). assert any("Copy-Item" in c and "'dist'" in c for c in cmds) assert not any("Compress-Archive" in c and "'dist'" in c for c in cmds) + + +# ── _parse_extra_env unit tests ──────────────────────────────────────────── + + +def test_parse_extra_env_empty_string_skipped() -> None: + """Line 62: empty strings in the iterable are silently skipped (continue branch).""" + result = build_module._parse_extra_env(["", "FOO=bar", ""]) + assert result == {"FOO": "bar"} + + +def test_parse_extra_env_no_equals_raises() -> None: + """Line 63-66: value with no '=' must raise BadParameter.""" + import click + + with pytest.raises(click.BadParameter, match="KEY=VALUE"): + build_module._parse_extra_env(["BADVALUE"]) + + +def test_parse_extra_env_empty_key_raises() -> None: + """Line 69: '=value' with empty key must raise BadParameter.""" + import click + + with pytest.raises(click.BadParameter, match="key cannot be empty"): + build_module._parse_extra_env(["=value"]) + + +# ── _zip_dir skips subdirectories ───────────────────────────────────────── + + +def test_zip_dir_skips_subdirectories(tmp_path: Path) -> None: + """Line 80->79: _zip_dir iterates rglob('*'); directories are skipped (is_file() == False).""" + import zipfile + + src = tmp_path / "src" + src.mkdir() + (src / "file.txt").write_text("hello", encoding="utf-8") + subdir = src / "subdir" + subdir.mkdir() + (subdir / "nested.txt").write_text("world", encoding="utf-8") + + archive = tmp_path / "out.zip" + build_module._zip_dir(src, archive) + + with zipfile.ZipFile(archive) as zf: + names = zf.namelist() + # Only files should appear; the bare subdirectory entry must not be present. + assert "file.txt" in names + assert str(Path("subdir") / "nested.txt") in names or "subdir/nested.txt" in names + # No entry that ends in '/' (a directory entry). + assert not any(n.endswith("/") for n in names) + + +# ── _report_build_output: stdout empty, stderr present ──────────────────── + + +def test_report_build_output_stderr_only(capsys: pytest.CaptureFixture[str]) -> None: + """Lines 108->111, 112-113: when stdout is empty, only stderr branch executes.""" + result = build_module._report_build_output("", "some error text") + captured = capsys.readouterr() + assert "some error text" in captured.err + assert captured.out.count("[build run]") == 2 # header + footer lines + # The tail should reflect the stderr content. + assert "some error text" in result + + +# ── _linux_build: neither host_source_dir nor clone_url ─────────────────── + + +def test_linux_build_no_source_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Line 189: _linux_build with no source raises ClickException.""" + import click + + monkeypatch.setattr(build_module, "SshTransport", _FakeTransport) + with pytest.raises(click.ClickException, match="either --host-source-dir or --clone-url"): + build_module._linux_build( + ip_address="10.0.0.1", + ssh_user="ci_build", + key_path=None, + known_hosts=None, + workdir="/opt/ci/build", + output_dir="/opt/ci/output", + host_source_dir=None, + clone_url=None, + clone_branch="main", + clone_commit="", + clone_submodules=False, + build_command="make", + artifact_source="dist", + extra_env={}, + skip_artifact=False, + ) + + +# ── _linux_build: clone_url + clone_commit triggers fetch+checkout ───────── + + +def test_linux_build_clone_commit_fetch_checkout(monkeypatch: pytest.MonkeyPatch) -> None: + """Lines 230-233: clone_commit causes 3 subprocess calls (clone, fetch, checkout) via SSH.""" + monkeypatch.setattr(build_module, "SshTransport", _FakeTransport) + build_module._linux_build( + ip_address="10.0.0.1", + ssh_user="ci_build", + key_path=None, + known_hosts=None, + workdir="/opt/ci/build", + output_dir="/opt/ci/output", + host_source_dir=None, + clone_url="http://repo/example.git", + clone_branch="main", + clone_commit="abc123", + clone_submodules=False, + build_command="true", + artifact_source="dist", + extra_env={}, + skip_artifact=True, + ) + instance = _FakeTransport.instances[0] + cmds = [c[0] for c in instance.runs] + assert any("git clone" in c for c in cmds) + assert any("fetch --depth 1 origin" in c and "abc123" in c for c in cmds) + assert any("checkout" in c and "abc123" in c for c in cmds) + + +# ── _windows_build: clone_url + clone_commit triggers fetch+checkout ─────── + + +def test_windows_build_clone_commit_fetch_checkout(monkeypatch: pytest.MonkeyPatch) -> None: + """Lines 315-321: clone_commit in windows path causes fetch+checkout run.""" + _patch(monkeypatch) + build_module._windows_build( + ip_address="10.0.0.7", + credential_target="BuildVMGuest", + workdir="C:\\CI\\build", + output_dir="C:\\CI\\output", + host_source_dir=None, + clone_url="https://example/repo.git", + clone_branch="main", + clone_commit="deadbeef", + clone_submodules=False, + build_command="echo done", + artifact_source="dist", + configuration="Release", + extra_env={}, + skip_artifact=True, + use_shared_cache=False, + ) + instance = _FakeTransport.instances[0] + cmds = [c[0] for c in instance.runs] + assert any("git clone" in c for c in cmds) + assert any("fetch --depth 1 origin" in c and "deadbeef" in c for c in cmds) + assert any("checkout" in c and "deadbeef" in c for c in cmds) + + +# ── _windows_build: no source raises ClickException ──────────────────────── + + +def test_windows_build_no_source_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Line 321: _windows_build with no source raises ClickException.""" + import click + + _patch(monkeypatch) + with pytest.raises(click.ClickException, match="either --host-source-dir or --clone-url"): + build_module._windows_build( + ip_address="10.0.0.7", + credential_target="BuildVMGuest", + workdir="C:\\CI\\build", + output_dir="C:\\CI\\output", + host_source_dir=None, + clone_url=None, + clone_branch="main", + clone_commit="", + clone_submodules=False, + build_command="echo done", + artifact_source="dist", + configuration="Release", + extra_env={}, + skip_artifact=False, + use_shared_cache=False, + ) + + +def test_windows_build_extra_env_semicolon_appended(monkeypatch: pytest.MonkeyPatch) -> None: + """Line 330: when extra_env is non-empty, env_setup gets '; ' appended before build command.""" + _patch(monkeypatch) + build_module._windows_build( + ip_address="10.0.0.7", + credential_target="BuildVMGuest", + workdir="C:\\CI\\build", + output_dir="C:\\CI\\output", + host_source_dir=None, + clone_url="https://example/repo.git", + clone_branch="main", + clone_commit="", + clone_submodules=False, + build_command="echo done", + artifact_source="dist", + configuration="Release", + extra_env={"MY_VAR": "hello"}, + skip_artifact=True, + use_shared_cache=False, + ) + instance = _FakeTransport.instances[0] + cmds = [c[0] for c in instance.runs] + # The build command should contain the injected env var followed by '; ' + assert any("$env:MY_VAR = 'hello'" in c for c in cmds) + + +def test_windows_build_build_failure_raises(monkeypatch: pytest.MonkeyPatch) -> None: + """Lines 365-367: when the build command returns non-zero, ClickException is raised.""" + import click + + _patch(monkeypatch) + + class _FailsBuild(_FakeTransport): + def run(self, script: str, *, check: bool = True) -> _FakeResult: + if "echo fail" in script: + return _FakeResult(stdout="", stderr="boom", returncode=1) + return super().run(script, check=check) + + monkeypatch.setattr(build_module, "WinRmTransport", _FailsBuild) + with pytest.raises(click.ClickException, match="build command failed"): + build_module._windows_build( + ip_address="10.0.0.7", + credential_target="BuildVMGuest", + workdir="C:\\CI\\build", + output_dir="C:\\CI\\output", + host_source_dir=None, + clone_url="https://example/repo.git", + clone_branch="main", + clone_commit="", + clone_submodules=False, + build_command="echo fail", + artifact_source="dist", + configuration="Release", + extra_env={}, + skip_artifact=False, + use_shared_cache=False, + ) + + +# ── _linux_build: artifact_source == output_dir → collecting in place ───── + + +def test_linux_build_artifact_source_equals_output_dir( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Lines 365, 370-371: when artifact_source is the output_dir itself, the + 'collecting in place' branch runs an existence/non-empty check.""" + monkeypatch.setattr(build_module, "SshTransport", _FakeTransport) + output_dir = "/opt/ci/output" + build_module._linux_build( + ip_address="10.0.0.1", + ssh_user="ci_build", + key_path=None, + known_hosts=None, + workdir="/opt/ci/build", + output_dir=output_dir, + host_source_dir=None, + clone_url="https://example/repo.git", + clone_branch="main", + clone_commit="", + clone_submodules=False, + build_command="true", + # Use the absolute output dir so srcpath == out + artifact_source=output_dir, + extra_env={}, + skip_artifact=False, + ) + instance = _FakeTransport.instances[0] + cmds = [c[0] for c in instance.runs] + # The 'collecting in place' guard command must appear. + assert any("if [ ! -d" in c and output_dir in c for c in cmds) + # The wipe-and-copy command must NOT appear. + assert not any("rm -rf" in c and "mkdir -p" in c and output_dir in c for c in cmds) + + +# ── build run CLI: ssh_key_path from config ──────────────────────────────── + + +def _make_config(ssh_key_path: Path | None = None) -> Config: + paths = Paths( + root=Path("/var/lib/ci"), + templates=Path("/var/lib/ci/templates"), + build_vms=Path("/var/lib/ci/build-vms"), + artifacts=Path("/var/lib/ci/artifacts"), + keys=Path("/var/lib/ci/keys"), + ) + return Config(paths=paths, backend=BackendConfig(), ssh_key_path=ssh_key_path) + + +def test_build_run_linux_ssh_key_from_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Line 548: when --ssh-key-path is omitted but config.ssh_key_path is set, it is used.""" + _patch(monkeypatch) + key = tmp_path / "ci_linux" + monkeypatch.setattr(build_module, "load_config", lambda: _make_config(ssh_key_path=key)) + + result = CliRunner().invoke( + cli, + [ + "build", + "run", + "--ip-address", + "10.0.0.5", + "--guest-os", + "linux", + "--clone-url", + "https://example/repo.git", + "--build-command", + "true", + "--skip-artifact", + # deliberately NO --ssh-key-path + ], + ) + assert result.exit_code == 0, result.output + # The transport must have been instantiated with the config key path. + instance = _FakeTransport.instances[0] + assert str(key) in (instance.kwargs.get("key_path") or instance.args[1] if len(instance.args) > 1 else "") or instance.kwargs.get("key_path") == str(key) + + +# ── build run CLI: linux TransportError → ClickException ────────────────── + + +def test_build_run_linux_transport_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Line 568: TransportError from _linux_build is caught and re-raised as ClickException.""" + _patch(monkeypatch) + monkeypatch.setattr(build_module, "load_config", lambda: _make_config()) + + class _RaisesTransport(_FakeTransport): + def __enter__(self) -> _RaisesTransport: + raise TransportError("connection refused") + + monkeypatch.setattr(build_module, "SshTransport", _RaisesTransport) + + result = CliRunner().invoke( + cli, + [ + "build", + "run", + "--ip-address", + "10.0.0.5", + "--guest-os", + "linux", + "--clone-url", + "https://example/repo.git", + "--build-command", + "true", + "--ssh-key-path", + str(tmp_path / "key"), + ], + ) + assert result.exit_code != 0 + assert "connection refused" in result.output + + +# ── build run CLI: windows TransportError → ClickException ──────────────── + + +def test_build_run_windows_transport_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Lines 591-592: TransportError from _windows_build is caught and re-raised as ClickException.""" + _patch(monkeypatch) + monkeypatch.setattr(build_module, "load_config", lambda: _make_config()) + + class _RaisesTransport(_FakeTransport): + def __enter__(self) -> _RaisesTransport: + raise TransportError("winrm unreachable") + + monkeypatch.setattr(build_module, "WinRmTransport", _RaisesTransport) + + result = CliRunner().invoke( + cli, + [ + "build", + "run", + "--ip-address", + "10.0.0.7", + "--guest-os", + "windows", + "--clone-url", + "https://example/repo.git", + ], + ) + assert result.exit_code != 0 + assert "winrm unreachable" in result.output diff --git a/tests/python/test_commands_job.py b/tests/python/test_commands_job.py index b606ce0..bd4ea20 100644 --- a/tests/python/test_commands_job.py +++ b/tests/python/test_commands_job.py @@ -653,3 +653,934 @@ def test_destroy_clone_releases_pool_ip(tmp_path: Path) -> None: leases = pool._read_leases() assert leases["10.0.0.1"] is None + + +# ── _probe_transport failure paths ────────────────────────────────────────── + + +def test_probe_transport_windows_transport_error_returns_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WinRM raises TransportError every attempt → deadline passes → False.""" + from ci_orchestrator.transport.errors import TransportError + + class _FailWinRm: + def __enter__(self) -> _FailWinRm: + raise TransportError("connection refused") + + def __exit__(self, *_: object) -> None: + return None + + def is_ready(self) -> bool: + return False + + monkeypatch.setattr(job_module, "WinRmTransport", lambda *a, **k: _FailWinRm()) + monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) + + class _Store: + def get(self, _t: str) -> Any: + from ci_orchestrator.credentials import Credential + + return Credential("ci", "pwd") + + monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _Store()) + + # Two-tick clock: first call returns time before deadline, second after. + seq = iter([0.0, 2.0]) + monkeypatch.setattr(job_module, "_now", lambda: next(seq)) + + result = job_module._probe_transport( + guest_os="windows", + ip_address="10.0.0.1", + deadline=1.0, + poll=0.001, + credential_target="GiteaPAT", + ssh_user="ci_build", + ssh_key_path=None, + ssh_known_hosts=None, + ) + assert result is False + + +def test_probe_transport_linux_transport_error_returns_false( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SSH raises TransportError every attempt → deadline passes → False.""" + from ci_orchestrator.transport.errors import TransportError + + class _FailSsh: + def __enter__(self) -> _FailSsh: + raise TransportError("ssh refused") + + def __exit__(self, *_: object) -> None: + return None + + def is_ready(self) -> bool: + return False + + monkeypatch.setattr(job_module, "SshTransport", lambda *a, **k: _FailSsh()) + monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) + + seq = iter([0.0, 2.0]) + monkeypatch.setattr(job_module, "_now", lambda: next(seq)) + + result = job_module._probe_transport( + guest_os="linux", + ip_address="10.0.0.1", + deadline=1.0, + poll=0.001, + credential_target="GiteaPAT", + ssh_user="ci_build", + ssh_key_path=None, + ssh_known_hosts=None, + ) + assert result is False + + +# ── _inject_guestinfo_ip empty netmask / gateway ──────────────────────────── + + +def test_inject_guestinfo_ip_omits_empty_netmask(tmp_path: Path) -> None: + """netmask='' skips the guestinfo.netmask line.""" + vmx = tmp_path / "test.vmx" + vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") + job_module._inject_guestinfo_ip(vmx, "10.0.0.5", "", "10.0.0.1") + content = vmx.read_text() + assert "guestinfo.netmask" not in content + assert 'guestinfo.ip-assignment = "10.0.0.5"' in content + assert 'guestinfo.gateway = "10.0.0.1"' in content + + +def test_inject_guestinfo_ip_omits_both_netmask_and_gateway(tmp_path: Path) -> None: + """netmask='' and gateway='' → only ip-assignment line written.""" + vmx = tmp_path / "test.vmx" + vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") + job_module._inject_guestinfo_ip(vmx, "10.0.0.5", "", "") + content = vmx.read_text() + assert "guestinfo.netmask" not in content + assert "guestinfo.gateway" not in content + assert 'guestinfo.ip-assignment = "10.0.0.5"' in content + + +# ── _redact_url_creds ─────────────────────────────────────────────────────── + + +def test_redact_url_creds_masks_credentials() -> None: + url = "https://user:secret@gitea.local/org/repo.git" + redacted = job_module._redact_url_creds(url) + assert "secret" not in redacted + assert "***" in redacted + assert "gitea.local" in redacted + + +def test_redact_url_creds_noop_when_no_creds() -> None: + url = "https://gitea.local/org/repo.git" + assert job_module._redact_url_creds(url) == url + + +# ── _host_clone ────────────────────────────────────────────────────────────── + + +def test_host_clone_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """_host_clone returns (tmp_root, src_dir) on subprocess success.""" + import subprocess + + call_log: list[list[str]] = [] + + def _fake_run(cmd: list[str], **kwargs: Any) -> Any: + call_log.append(cmd) + # Create the src_dir so the function can succeed. + if cmd[0] == "git" and cmd[1] == "clone": + src = Path(cmd[-1]) + src.mkdir(parents=True, exist_ok=True) + result = subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + return result + + monkeypatch.setattr(job_module.subprocess, "run", _fake_run) + + tmp_root, _src_dir = job_module._host_clone( + repo_url="http://gitea.local/org/repo.git", + branch="main", + commit="", + submodules=False, + ) + try: + assert _src_dir.name == "src" + assert _src_dir.parent == tmp_root + assert "git" in call_log[0] + assert "clone" in call_log[0] + # No fetch/checkout when commit is empty. + assert len(call_log) == 1 + finally: + import shutil + + shutil.rmtree(tmp_root, ignore_errors=True) + + +def test_host_clone_with_commit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """_host_clone issues fetch + checkout when commit is non-empty.""" + import subprocess + + call_log: list[list[str]] = [] + + def _fake_run(cmd: list[str], **kwargs: Any) -> Any: + call_log.append(cmd) + if cmd[0] == "git" and cmd[1] == "clone": + src = Path(cmd[-1]) + src.mkdir(parents=True, exist_ok=True) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(job_module.subprocess, "run", _fake_run) + + tmp_root, _src_dir2 = job_module._host_clone( + repo_url="http://gitea.local/org/repo.git", + branch="main", + commit="abc123", + submodules=True, + ) + import shutil + + shutil.rmtree(tmp_root, ignore_errors=True) + # clone + fetch + checkout = 3 calls. + assert len(call_log) == 3 + assert "fetch" in call_log[1] + assert "checkout" in call_log[2] + # submodules flag included. + assert "--recurse-submodules" in call_log[0] + + +def test_host_clone_called_process_error_raises_click_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CalledProcessError from git → ClickException with redacted message.""" + import subprocess + + def _fail_run(cmd: list[str], **kwargs: Any) -> Any: + raise subprocess.CalledProcessError( + 1, cmd, output="", stderr="fatal: repository not found" + ) + + monkeypatch.setattr(job_module.subprocess, "run", _fail_run) + + with pytest.raises(click.ClickException) as exc_info: + job_module._host_clone( + repo_url="http://user:pass@gitea.local/org/repo.git", + branch="main", + commit="", + submodules=False, + ) + assert "host-side git clone failed" in str(exc_info.value) + # URL credentials must be redacted. + assert "pass" not in str(exc_info.value) + + +# ── _destroy_clone pool release raises ────────────────────────────────────── + + +def test_destroy_clone_pool_release_raises_echoes_warning( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """pool.release raising must not propagate — warning echoed instead.""" + from ci_orchestrator.ip_pool import IpSlotPool + + class _BrokenPool(IpSlotPool): + def release(self, ip: str) -> None: + raise RuntimeError("lock file busy") + + lease = tmp_path / "pool.json" + pool = _BrokenPool(["10.0.0.1"], "255.255.255.0", "", lease) + pool.acquire("owner") # mark as leased + + # Must not raise. + job_module._destroy_clone(None, None, None, pool=pool, pool_ip="10.0.0.1") + + # Warning should have been echoed to stderr. + captured = capsys.readouterr() + assert "release failed" in captured.err or "release" in captured.err + + +# ── CLI: BackendNotAvailable → ClickException ─────────────────────────────── + + +def test_job_backend_not_available( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """load_backend raising BackendNotAvailable → ClickException with message.""" + from ci_orchestrator.backends.errors import BackendNotAvailable + + backend = _FakeBackend() + _patch_common(monkeypatch, backend) + # Override load_backend AFTER _patch_common so it takes precedence. + monkeypatch.setattr( + job_module, + "load_backend", + lambda _cfg: (_ for _ in ()).throw(BackendNotAvailable("vmrun not found")), + ) + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "backend unavailable" in result.output + + +# ── CLI: clone VMX missing after successful clone ──────────────────────────── + + +class _FakeBackendNoVmx(_FakeBackend): + """clone_linked succeeds but never writes the VMX file.""" + + def clone_linked( + self, + template: str, + snapshot: str, + name: str, + destination: str | None = None, + ) -> VmHandle: + self.calls.append("clone") + assert destination is not None + # Intentionally do NOT write any file. + return VmHandle(identifier=destination or "", name=name) + + +def test_job_clone_vmx_missing_after_success( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """backend.clone_linked returns but VMX is absent → ClickException.""" + backend = _FakeBackendNoVmx() + _patch_common(monkeypatch, backend) + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "backend reported success but clone VMX is missing" in result.output + + +# ── CLI: --vmrun-path override ─────────────────────────────────────────────── + + +def test_job_vmrun_path_creates_workstation_backend( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """--vmrun-path bypasses load_backend and uses WorkstationVmrunBackend.""" + import ci_orchestrator.backends.workstation as _ws_mod + + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + captured_vmrun_paths: list[str] = [] + + class _FakeWorkstation(_FakeBackend): + def __init__(self, vmrun_path: str) -> None: + super().__init__(running_after=0) + captured_vmrun_paths.append(vmrun_path) + + monkeypatch.setattr(_ws_mod, "WorkstationVmrunBackend", _FakeWorkstation) + + fake_vmrun = tmp_path / "vmrun.exe" + fake_vmrun.write_text("fake", encoding="utf-8") + + result = CliRunner().invoke( + cli, + _common_args( + template_vmx, + clone_base=tmp_path / "vms", + artifact_base=tmp_path / "art", + extra=["--vmrun-path", str(fake_vmrun)], + ), + ) + assert result.exit_code == 0, result.output + assert captured_vmrun_paths == [str(fake_vmrun)] + + +# ── CLI: --guest-cpu / --guest-memory-mb override on Linux branch ──────────── + + +def test_job_linux_vmx_override_cpu_memory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """--guest-cpu and --guest-memory-mb are applied to the Linux clone VMX.""" + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + # We capture the clone VMX path so we can check it was modified. + written_vmx: list[Path] = [] + real_apply = job_module._apply_vmx_overrides + + def _spy(vmx_path: Path, cpu: int, memory_mb: int) -> None: + written_vmx.append(vmx_path) + real_apply(vmx_path, cpu, memory_mb) + + monkeypatch.setattr(job_module, "_apply_vmx_overrides", _spy) + + result = CliRunner().invoke( + cli, + _common_args( + template_vmx, + clone_base=tmp_path / "vms", + artifact_base=tmp_path / "art", + extra=["--guest-cpu", "4", "--guest-memory-mb", "2048"], + ), + ) + assert result.exit_code == 0, result.output + assert len(written_vmx) == 1, "_apply_vmx_overrides should have been called once" + + +# ── CLI: _wait_for_ip returns None → timeout error ─────────────────────────── + + +def test_job_wait_for_ip_timeout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """When _wait_for_ip returns None the job raises 'timeout waiting for guest IP'.""" + backend = _FakeBackend(running_after=0, ip=None) + _patch_common(monkeypatch, backend) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "timeout waiting for guest IP" in result.output + assert "delete" in backend.calls + + +# ── CLI: transport probe times out ────────────────────────────────────────── + + +def test_job_transport_probe_timeout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """_probe_transport returning False → 'timeout waiting for guest transport'.""" + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend, transport_ready=False) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "timeout waiting for guest transport" in result.output + assert "delete" in backend.calls + + +# ── CLI: --host-clone flag exercises _host_clone ──────────────────────────── + + +def test_job_host_clone_flag( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """--host-clone invokes _host_clone and passes host_source_dir to build.""" + backend = _FakeBackend(running_after=0) + calls = _patch_common(monkeypatch, backend) + + src_root = tmp_path / "hostclone" + src_dir = src_root / "src" + src_dir.mkdir(parents=True) + + def _fake_host_clone( + repo_url: str, branch: str, commit: str, submodules: bool + ) -> tuple[Path, Path]: + return src_root, src_dir + + monkeypatch.setattr(job_module, "_host_clone", _fake_host_clone) + + result = CliRunner().invoke( + cli, + _common_args( + template_vmx, + clone_base=tmp_path / "vms", + artifact_base=tmp_path / "art", + extra=["--host-clone"], + ), + ) + assert result.exit_code == 0, result.output + assert calls["linux_build"][0]["host_source_dir"] == str(src_dir) + assert calls["linux_build"][0]["clone_url"] is None + + +# ── CLI: ip_pool configured but guest_os is linux → pool skipped ───────────── + + +def test_job_ip_pool_skipped_for_linux( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """ip_pool configured but Linux guest → pool not used (line 523 guard).""" + from ci_orchestrator.config import Config, IpPoolConfig + + backend = _FakeBackend(running_after=0) + calls = _patch_common(monkeypatch, backend) + + pool_cfg = IpPoolConfig( + addresses=["192.168.1.100"], + netmask="255.255.255.0", + gateway="192.168.1.1", + lease_file=tmp_path / "ip-pool.json", + ) + real_cfg = job_module.load_config() + patched_cfg = Config( + paths=real_cfg.paths, + backend=real_cfg.backend, + vmrun_path=real_cfg.vmrun_path, + ssh_key_path=real_cfg.ssh_key_path, + guest_cred_target=real_cfg.guest_cred_target, + ip_pool=pool_cfg, + ) + monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code == 0, result.output + # Pool was not used → backend.get_ip was called normally. + assert calls["linux_build"], "linux_build should have been invoked" + assert "pre-assigned" not in result.output + + +# ── CLI: KeyboardInterrupt → sys.exit(130) ────────────────────────────────── + + +def test_job_keyboard_interrupt_triggers_cleanup( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """KeyboardInterrupt during probe → _destroy_clone called, sys.exit(130).""" + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + def _raise_kbi(**kwargs: Any) -> bool: + raise KeyboardInterrupt() + + monkeypatch.setattr(job_module, "_probe_transport", _raise_kbi) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code == 130 + assert "delete" in backend.calls + + +# ── CLI: ClickException → _destroy_clone called ───────────────────────────── + + +def test_job_click_exception_triggers_cleanup( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """Any ClickException inside try → cleanup in except branch (line 557).""" + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + def _raise_click(**kwargs: Any) -> bool: + raise click.ClickException("forced error") + + monkeypatch.setattr(job_module, "_probe_transport", _raise_click) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "forced error" in result.output + assert "delete" in backend.calls + + +# ── CLI: generic Exception → wrapped as ClickException ────────────────────── + + +def test_job_generic_exception_wrapped( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """Unhandled Exception inside try → ClickException (line 568).""" + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + def _boom(**kwargs: Any) -> bool: + raise RuntimeError("unexpected boom") + + monkeypatch.setattr(job_module, "_probe_transport", _boom) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "unexpected boom" in result.output + assert "delete" in backend.calls + + +# ── CLI: _write_manifest raises OSError → warning, job continues ───────────── + + +def test_job_manifest_oserror_warning( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """OSError from _write_manifest → warning echoed, job still exits 0.""" + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + def _bad_manifest(host_dir: Path, job_id: str, commit: str) -> int: + raise OSError("disk full") + + monkeypatch.setattr(job_module, "_write_manifest", _bad_manifest) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code == 0, result.output + assert "manifest write failed" in result.output + + +# ── CLI: HTTP URL + Gitea credential → authed URL built (lines 668-669) ────── + + +def test_job_gitea_credential_injected_into_url( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """Gitea credential from keyring is embedded in the clone URL.""" + backend = _FakeBackend(running_after=0) + calls = _patch_common(monkeypatch, backend) + + # _patch_common already monkeypatches KeyringCredentialStore to return + # Credential("ci", "pwd"). Verify the authed URL reaches _linux_build. + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code == 0, result.output + clone_url = calls["linux_build"][0]["clone_url"] + assert clone_url is not None + assert "ci:pwd@" in clone_url + + +# ── _probe_transport: is_ready() returns False (no exception) ──────────────── + + +def test_probe_transport_winrm_not_ready_then_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """WinRM transport connects but is_ready()=False → deadline → returns False.""" + + class _NotReadyWinRm: + def __enter__(self) -> _NotReadyWinRm: + return self + + def __exit__(self, *_: object) -> None: + return None + + def is_ready(self) -> bool: + return False + + monkeypatch.setattr(job_module, "WinRmTransport", lambda *a, **k: _NotReadyWinRm()) + monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) + + class _Store: + def get(self, _t: str) -> Any: + from ci_orchestrator.credentials import Credential + + return Credential("ci", "pwd") + + monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _Store()) + + # Two-tick clock: first iteration is before deadline, second is after. + seq = iter([0.0, 2.0]) + monkeypatch.setattr(job_module, "_now", lambda: next(seq)) + + result = job_module._probe_transport( + guest_os="windows", + ip_address="10.0.0.1", + deadline=1.0, + poll=0.001, + credential_target="GiteaPAT", + ssh_user="ci_build", + ssh_key_path=None, + ssh_known_hosts=None, + ) + assert result is False + + +# ── _destroy_clone: backend.delete raises BackendError ─────────────────────── + + +def test_destroy_clone_delete_backend_error_echoes_warning( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + """BackendError from backend.delete → warning echoed, no exception raised.""" + backend = _FakeBackend(delete_fail=True) + handle = VmHandle(identifier="/tmp/fake.vmx", name="fake") + + # Must not raise. + job_module._destroy_clone(backend, handle, None) # type: ignore[arg-type] + + captured = capsys.readouterr() + assert "deleteVM failed" in captured.err or "deleteVM" in captured.err + + +# ── _destroy_clone: _try_remove_dir returns False (partial removal) ────────── + + +def test_destroy_clone_try_remove_dir_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """_try_remove_dir returning False → warning echoed.""" + monkeypatch.setattr(job_module, "_try_remove_dir", lambda _p: False) + + clone_dir = tmp_path / "clone" + clone_dir.mkdir() + + job_module._destroy_clone(None, None, clone_dir) + + captured = capsys.readouterr() + assert "could not fully remove" in captured.err + + +# ── CLI: explicit --guest-os linux skips auto-detection (line 523) ─────────── + + +def test_job_explicit_guest_os_linux( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """--guest-os linux takes the else branch (no auto-detect) and lowercases.""" + backend = _FakeBackend(running_after=0) + calls = _patch_common(monkeypatch, backend) + + result = CliRunner().invoke( + cli, + _common_args( + template_vmx, + clone_base=tmp_path / "vms", + artifact_base=tmp_path / "art", + extra=["--guest-os", "linux"], + ), + ) + assert result.exit_code == 0, result.output + # auto-detect message must NOT appear. + assert "auto-detected" not in result.output + assert calls["linux_build"] + + +# ── CLI: backend.start raises BackendError (lines 545-546) ────────────────── + + +def test_job_start_backend_error( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """BackendError from backend.start → ClickException 'vmrun start failed'.""" + backend = _FakeBackend(start_fail=True) + _patch_common(monkeypatch, backend) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "vmrun start failed" in result.output + assert "delete" in backend.calls + + +# ── CLI: _wait_running returns False → timeout (line 550) ──────────────────── + + +def test_job_wait_running_timeout( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """_wait_running returning False → ClickException 'timeout waiting for VM to be running'.""" + backend = _FakeBackend(running_after=999) # is_running always False + _patch_common(monkeypatch, backend) + + # Make _wait_running always return False immediately. + monkeypatch.setattr(job_module, "_wait_running", lambda *a, **k: False) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code != 0 + assert "timeout waiting for VM to be running" in result.output + assert "delete" in backend.calls + + +# ── CLI: ssh_key_path from config used when not specified on CLI ────────────── + + +def test_job_ssh_key_from_config( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """Config.ssh_key_path is used as key_path when --ssh-key-path is not given (line 568).""" + from ci_orchestrator.config import Config + + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + ssh_key = tmp_path / "id_rsa" + ssh_key.write_text("fake-key", encoding="utf-8") + + real_cfg = job_module.load_config() + patched_cfg = Config( + paths=real_cfg.paths, + backend=real_cfg.backend, + vmrun_path=real_cfg.vmrun_path, + ssh_key_path=ssh_key, + guest_cred_target=real_cfg.guest_cred_target, + ip_pool=real_cfg.ip_pool, + ) + monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg) + + probe_kwargs: list[dict[str, Any]] = [] + + def _spy_probe(**kwargs: Any) -> bool: + probe_kwargs.append(kwargs) + return True + + monkeypatch.setattr(job_module, "_probe_transport", _spy_probe) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code == 0, result.output + assert probe_kwargs[0]["ssh_key_path"] == str(ssh_key) + + +# ── CLI: Gitea credential get raises → URL used unauthenticated (line 435-436) ─ + + +def test_job_gitea_credential_raises_uses_unauthenticated( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """KeyringCredentialStore.get raising → except pass → unauthenticated URL.""" + backend = _FakeBackend(running_after=0) + calls = _patch_common(monkeypatch, backend) + + class _FailStore: + def get(self, _t: str) -> Any: + raise RuntimeError("keyring unavailable") + + monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _FailStore()) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code == 0, result.output + # URL falls back to unauthenticated — no user:pwd@ prefix. + clone_url = calls["linux_build"][0]["clone_url"] + assert clone_url == "http://gitea.local/org/repo.git" + + +# ── CLI: non-HTTP repo URL skips credential injection (line 425->438) ───────── + + +def test_job_non_http_repo_url_skips_credential_injection( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """git:// URL skips the credential-injection block entirely.""" + backend = _FakeBackend(running_after=0) + calls = _patch_common(monkeypatch, backend) + + non_http_url = "git://gitea.local/org/repo.git" + + args = [ + "job", + "--job-id", "ci-test-git", + "--repo-url", non_http_url, + "--branch", "main", + "--template-path", str(template_vmx), + "--clone-base-dir", str(tmp_path / "vms"), + "--artifact-base-dir", str(tmp_path / "art"), + "--ready-timeout", "30", + "--poll-interval", "1", + ] + result = CliRunner().invoke(cli, args) + assert result.exit_code == 0, result.output + # Clone URL must be the original, unmodified URL. + clone_url = calls["linux_build"][0]["clone_url"] + assert clone_url == non_http_url + + +# ── CLI: missing --template-path → ClickException (line 440) ───────────────── + + +def test_job_missing_template_path_raises( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Omitting --template-path raises 'template-path is required'.""" + backend = _FakeBackend() + _patch_common(monkeypatch, backend) + + args = [ + "job", + "--job-id", "ci-test-ntp", + "--repo-url", "http://gitea.local/org/repo.git", + "--branch", "main", + "--clone-base-dir", str(tmp_path / "vms"), + "--artifact-base-dir", str(tmp_path / "art"), + "--ready-timeout", "30", + "--poll-interval", "1", + ] + result = CliRunner().invoke(cli, args) + assert result.exit_code != 0 + assert "--template-path is required" in result.output + + +# ── CLI: --commit echoes commit line (line 488) ─────────────────────────────── + + +def test_job_commit_echoed_in_header( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """When --commit is given the header echoes it (covers the if commit: branch).""" + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + result = CliRunner().invoke( + cli, + _common_args( + template_vmx, + clone_base=tmp_path / "vms", + artifact_base=tmp_path / "art", + extra=["--commit", "deadbeef"], + ), + ) + assert result.exit_code == 0, result.output + assert "deadbeef" in result.output + + +# ── CLI: SIGTERM handler fires KeyboardInterrupt (line 479) ────────────────── + + +def test_job_sigterm_triggers_keyboard_interrupt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path +) -> None: + """SIGTERM handler raises KeyboardInterrupt — invoke it directly to cover line 479.""" + import signal as _signal + + backend = _FakeBackend(running_after=0) + _patch_common(monkeypatch, backend) + + registered_handler: list[Any] = [] + + real_signal = _signal.signal + + def _capture_signal(sig: int, handler: Any) -> Any: + if sig == _signal.SIGTERM: + registered_handler.append(handler) + return real_signal(sig, handler) + + monkeypatch.setattr(_signal, "signal", _capture_signal) + + # Also make probe raise so the job exits early (keeping test fast). + monkeypatch.setattr(job_module, "_probe_transport", lambda **k: True) + + result = CliRunner().invoke( + cli, + _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), + ) + assert result.exit_code == 0, result.output + + # Now directly invoke the SIGTERM handler to cover line 479. + assert registered_handler, "signal.signal(SIGTERM, ...) should have been called" + with pytest.raises(KeyboardInterrupt): + registered_handler[-1](_signal.SIGTERM, None) diff --git a/tests/python/test_commands_monitor.py b/tests/python/test_commands_monitor.py index b359dac..10ac8fb 100644 --- a/tests/python/test_commands_monitor.py +++ b/tests/python/test_commands_monitor.py @@ -659,3 +659,49 @@ def test_runner_pruned_old_entries( cli, ["monitor", "runner", "--state-dir", str(tmp_path)] ) assert result.exit_code == 0 + + +# ── NEW: coverage gap tests ────────────────────────────────────────────────── + + +def test_disk_alert_json_includes_message_field(monkeypatch: pytest.MonkeyPatch) -> None: + """Lines 186-187: disk below threshold + --json → JSON has 'message' key, exit 1.""" + monkeypatch.setattr(mon.shutil, "disk_usage", lambda _p: _fake_usage(free_gb=5)) + result = CliRunner().invoke( + cli, ["monitor", "disk", "--min-free-gb", "50", "--json"] + ) + assert result.exit_code == 1 + payload = json.loads(result.output.strip()) + assert payload["status"] == "alert" + assert "message" in payload + assert "below" in payload["message"] + + +def test_runner_running_gitea_online_no_webhook( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Lines 398->402: online==0 but no webhook_url → branch skips _post_webhook, + continues to return (line 402). Covers the 398->402 branch (webhook_url falsy).""" + monkeypatch.setattr(mon, "_service_status", lambda _s: "Running") + monkeypatch.setattr(mon, "_gitea_runners_online", lambda *_a, **_kw: 0) + + class _Store: + def get(self, _target: str) -> Any: + from ci_orchestrator.credentials import Credential + return Credential("u", "tok") + + import ci_orchestrator.credentials as creds + monkeypatch.setattr(creds, "KeyringCredentialStore", _Store) + + result = CliRunner().invoke( + cli, + [ + "monitor", "runner", + "--state-dir", str(tmp_path), + "--gitea-url", "https://g/", + "--gitea-credential-target", "tgt", + # no --webhook-url → webhook_url is "" + ], + ) + assert result.exit_code == 0 + assert "0 online runners" in result.output diff --git a/tests/python/test_commands_report.py b/tests/python/test_commands_report.py index 66b4ef0..46a91b5 100644 --- a/tests/python/test_commands_report.py +++ b/tests/python/test_commands_report.py @@ -210,3 +210,37 @@ def test_report_job_failed_filter_no_results(tmp_path: Path) -> None: ) assert result.exit_code == 0 assert "No matching jobs" in result.output + + +# ── NEW: coverage gap tests ─────────────────────────────────────────────────── + + +def test_report_job_detail_no_failure_event(tmp_path: Path) -> None: + """Lines 201->203: detail view where events exist but no failure → no 'Error:' line.""" + _write_jsonl(tmp_path / "run-ok" / "invoke-ci.jsonl", _success_events("run-ok")) + result = CliRunner().invoke( + cli, + ["report", "job", "--log-dir", str(tmp_path), "--job-id", "run-ok"], + ) + assert result.exit_code == 0, result.output + assert "Job: run-ok" in result.output + # No failure event → no "Error:" line should appear + assert "Error:" not in result.output + + +def test_report_job_list_skips_empty_jsonl(tmp_path: Path) -> None: + """Line 218: list mode with an empty/unreadable JSONL file → that file is skipped.""" + # Write one valid job + _write_jsonl(tmp_path / "run-good" / "invoke-ci.jsonl", _success_events("run-good")) + # Write an empty JSONL (no events) — should be silently skipped + empty_jsonl = tmp_path / "run-empty" / "invoke-ci.jsonl" + empty_jsonl.parent.mkdir(parents=True) + empty_jsonl.write_text("", encoding="utf-8") + + result = CliRunner().invoke(cli, ["report", "job", "--log-dir", str(tmp_path)]) + assert result.exit_code == 0, result.output + # Only the valid job appears + assert "run-good" in result.output + # The empty dir's name must not appear as a row (it was skipped) + # We can confirm by checking only one data row exists: "success" appears once + assert result.output.count("success") >= 1