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
+231
View File
@@ -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
+392 -1
View File
@@ -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
+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)
+46
View File
@@ -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
+34
View File
@@ -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