"""Tests for ``ci_orchestrator build run``.""" from __future__ import annotations from pathlib import Path from typing import Any, ClassVar import pytest from click.testing import CliRunner import ci_orchestrator.commands.build as build_module from ci_orchestrator.__main__ import cli from ci_orchestrator.credentials import Credential from ci_orchestrator.transport.errors import TransportCommandError class _FakeResult: def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0) -> None: self.stdout = stdout self.stderr = stderr self.returncode = returncode @property def ok(self) -> bool: return self.returncode == 0 class _FakeTransport: """Records every ``run/copy/fetch`` call.""" instances: ClassVar[list[_FakeTransport]] = [] def __init__(self, *args: Any, **kwargs: Any) -> None: self.args = args self.kwargs = kwargs self.runs: list[tuple[str, bool]] = [] self.copies: list[tuple[str, str]] = [] self.fetches: list[tuple[str, str]] = [] self.run_results: dict[int, _FakeResult] = {} self.default_result = _FakeResult(stdout="ok") type(self).instances.append(self) def __enter__(self) -> _FakeTransport: return self def __exit__(self, *_e: object) -> None: return None def run(self, script: str, *, check: bool = True) -> _FakeResult: self.runs.append((script, check)) result = self.run_results.get(len(self.runs) - 1, self.default_result) if check and not result.ok: raise TransportCommandError(result.returncode, result.stdout, result.stderr) return result def run_streaming( self, script: str, *, check: bool = True, timeout: float | None = None ) -> _FakeResult: # Tests don't exercise live streaming; delegate to run() so # capture + check semantics (and _BuildFails overrides) hold. return self.run(script, check=check) def copy(self, local_path: str, remote_path: str) -> None: self.copies.append((str(local_path), remote_path)) def fetch(self, remote_path: str, local_path: str) -> None: self.fetches.append((remote_path, str(local_path))) @pytest.fixture(autouse=True) def _reset_instances() -> None: _FakeTransport.instances = [] def _patch(monkeypatch: pytest.MonkeyPatch) -> type[_FakeTransport]: class _Store: def get(self, _t: str) -> Credential: return Credential("ci", "pwd") monkeypatch.setattr(build_module, "WinRmTransport", _FakeTransport) monkeypatch.setattr(build_module, "SshTransport", _FakeTransport) monkeypatch.setattr(build_module, "KeyringCredentialStore", lambda: _Store()) return _FakeTransport # ── parameter validation ─────────────────────────────────────────────────── def test_build_run_requires_source(monkeypatch: pytest.MonkeyPatch) -> None: _patch(monkeypatch) result = CliRunner().invoke( cli, ["build", "run", "--ip-address", "10.0.0.1"] ) assert result.exit_code != 0 assert "either --host-source-dir or --clone-url" in result.output def test_build_run_rejects_both_modes(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _patch(monkeypatch) src = tmp_path / "src" src.mkdir() result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.1", "--host-source-dir", str(src), "--clone-url", "https://example/repo.git", ], ) assert result.exit_code != 0 assert "not both" in result.output def test_build_run_rejects_missing_source_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _patch(monkeypatch) result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.1", "--host-source-dir", str(tmp_path / "ghost"), ], ) assert result.exit_code != 0 assert "does not exist" in result.output def test_build_run_rejects_bad_extra_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _patch(monkeypatch) src = tmp_path / "src" src.mkdir() result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.1", "--guest-os", "linux", "--host-source-dir", str(src), "--build-command", "make", "--extra-env", "NO_EQUALS", ], ) assert result.exit_code != 0 assert "KEY=VALUE" in result.output # ── happy path: Linux ────────────────────────────────────────────────────── def test_build_run_linux_clone_url_happy_path( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.5", "--guest-os", "linux", "--clone-url", "https://example/repo.git", "--clone-branch", "feature/x", "--clone-commit", "deadbeef", "--clone-submodules", "--build-command", "make all", "--guest-linux-work-dir", "/work", "--guest-linux-output-dir", "/out", "--guest-artifact-source", "build", "--extra-env", "FOO=bar baz", "--extra-env", "TOKEN=secret", "--ssh-key-path", str(tmp_path / "key"), ], ) assert result.exit_code == 0, result.output instance = _FakeTransport.instances[0] cmds = [c[0] for c in instance.runs] assert any("rm -rf /work && mkdir -p /work" in c for c in cmds) assert any("git clone --depth 1 --branch feature/x" in c for c in cmds) assert any("--recurse-submodules" in c for c in cmds) assert any("checkout deadbeef" in c for c in cmds) # extra-env exported before build assert any("export FOO='bar baz'" in c and "export TOKEN=secret" in c for c in cmds) assert any("cd /work && make all" in c for c in cmds) # artifact stage uses the requested source dir assert any("/work/build" in c for c in cmds) def test_build_run_linux_host_source_dir( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) src = tmp_path / "src" src.mkdir() (src / "hello.txt").write_text("hi", encoding="utf-8") result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.5", "--guest-os", "linux", "--host-source-dir", str(src), "--build-command", "true", "--ssh-key-path", str(tmp_path / "key"), ], ) assert result.exit_code == 0, result.output instance = _FakeTransport.instances[0] # A single tarball was uploaded. assert len(instance.copies) == 1 local, remote = instance.copies[0] assert local.endswith(".tar.gz") assert remote.startswith("/tmp/") # Extraction command was invoked. assert any("tar -xzf" in c[0] for c in instance.runs) def test_build_run_linux_skip_artifact( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.5", "--guest-os", "linux", "--clone-url", "https://example/repo.git", "--build-command", "make", "--skip-artifact", "--ssh-key-path", str(tmp_path / "key"), ], ) assert result.exit_code == 0, result.output cmds = [c[0] for c in _FakeTransport.instances[0].runs] # No staging command should run. assert not any("/opt/ci/output" in c for c in cmds) def test_build_run_linux_build_failure_exits_nonzero( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) # Make the build command (4th run: rm/git clone/checkout-skipped/build) # return non-zero. Index of build run varies; easiest: make any non-zero # result for the run whose script contains 'cd '. src = tmp_path / "src" src.mkdir() class _BuildFails(_FakeTransport): def run(self, script: str, *, check: bool = True) -> _FakeResult: if "cd /opt/ci/build && make" in script: self.runs.append((script, check)) return _FakeResult(stdout="boom\n", stderr="err\n", returncode=2) return super().run(script, check=check) monkeypatch.setattr(build_module, "SshTransport", _BuildFails) result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.5", "--guest-os", "linux", "--host-source-dir", str(src), "--ssh-key-path", str(tmp_path / "key"), ], ) assert result.exit_code != 0 assert "build command failed (exit 2)" in result.output # ── happy path: Windows ──────────────────────────────────────────────────── def test_build_run_windows_clone_url_default_dotnet( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.7", "--guest-os", "windows", "--clone-url", "https://example/repo.git", "--use-shared-cache", ], ) assert result.exit_code == 0, result.output cmds = [c[0] for c in _FakeTransport.instances[0].runs] assert any("git clone --depth 1" in c for c in cmds) assert any("dotnet restore" in c for c in cmds) assert any("NUGET_PACKAGES" in c for c in cmds) def test_build_run_windows_host_source_dir( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: _patch(monkeypatch) src = tmp_path / "src" src.mkdir() (src / "a.cs").write_text("//", encoding="utf-8") result = CliRunner().invoke( cli, [ "build", "run", "--ip-address", "10.0.0.7", "--host-source-dir", str(src), "--build-command", "echo built > dist\\out.txt", "--guest-artifact-source", "dist", ], ) assert result.exit_code == 0, result.output instance = _FakeTransport.instances[0] assert len(instance.copies) == 1 assert instance.copies[0][1] == "C:\\CI\\src-transfer.zip" cmds = [c[0] for c in instance.runs] assert any("Expand-Archive" in c for c in cmds) # 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)