Add coverage gap tests for command functionalities
Lint / pssa (push) Successful in 10s
Lint / python (push) Successful in 1m22s

- 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.
This commit is contained in:
2026-05-26 21:18:20 +02:00
parent 0a1c455e54
commit 8dc7a4fb99
5 changed files with 1634 additions and 1 deletions
+931
View File
@@ -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)