"""Tests for ``ci_orchestrator vm new``. Migrated from Pester ``tests/New-BuildVM.Tests.ps1``. Preserves: * parameter validation: missing template VMX rejected * failure path: backend clone failure cleans up the partial clone dir * clone naming: ``Clone_{JobId}_{yyyyMMdd_HHmmss}`` * output: stdout final line is the clone VMX path """ from __future__ import annotations from pathlib import Path from typing import Any import pytest from click.testing import CliRunner import ci_orchestrator.commands.vm as vm_module from ci_orchestrator.__main__ import cli from ci_orchestrator.backends.errors import BackendOperationFailed from ci_orchestrator.backends.protocol import VmHandle class _FakeBackend: """Records calls and creates the destination VMX on success.""" def __init__(self, *, fail: bool = False) -> None: self.fail = fail self.calls: list[dict[str, Any]] = [] def clone_linked( self, template: str, snapshot: str, name: str, destination: str | None = None, ) -> VmHandle: self.calls.append( { "template": template, "snapshot": snapshot, "name": name, "destination": destination, } ) if self.fail: raise BackendOperationFailed("clone", 1, "Error: source not found") # Real vmrun creates dest dir + VMX file. Mirror that here so the # post-clone existence check passes. assert destination is not None dst = Path(destination) dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text("config.version = \"8\"", encoding="utf-8") return VmHandle(identifier=str(dst), name=name) @pytest.fixture def fake_template(tmp_path: Path) -> Path: p = tmp_path / "WinBuild2025.vmx" p.write_text("config.version = \"8\"", encoding="utf-8") return p def test_vm_new_rejects_missing_template(tmp_path: Path) -> None: """Pester migration: throws when TemplatePath does not exist.""" missing = tmp_path / "ghost.vmx" base = tmp_path / "build-vms" result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(missing), "--clone-base-dir", str(base), "--job-id", "test-1", ], ) assert result.exit_code != 0 assert "Template VMX not found" in result.output def test_vm_new_clone_failure_cleans_up_partial_dir( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: """Pester migration: vmrun failure removes the partially-created clone dir.""" base = tmp_path / "build-vms" backend = _FakeBackend(fail=True) monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(fake_template), "--clone-base-dir", str(base), "--job-id", "test-fail", ], ) assert result.exit_code != 0 assert "clone failed" in result.output leftovers = [p for p in base.iterdir() if p.is_dir() and "test-fail" in p.name] assert leftovers == [] def test_vm_new_clone_name_format_and_stdout( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: """Pester migration: clone folder is ``Clone_{JobId}_{yyyyMMdd_HHmmss}`` and the final stdout line is the clone VMX path.""" base = tmp_path / "build-vms" backend = _FakeBackend() monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(fake_template), "--clone-base-dir", str(base), "--job-id", "run-42", ], ) assert result.exit_code == 0, result.output final = result.output.strip().splitlines()[-1] assert final.endswith(".vmx") assert "Clone_run-42_" in final # Format check: timestamp is 8 digits + underscore + 6 digits. import re assert re.search(r"Clone_run-42_\d{8}_\d{6}\.vmx$", final) def test_vm_new_passes_snapshot_default( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: base = tmp_path / "build-vms" backend = _FakeBackend() monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(fake_template), "--clone-base-dir", str(base), "--job-id", "run-99", ], ) assert result.exit_code == 0, result.output assert backend.calls[0]["snapshot"] == "BaseClean" def test_vm_new_pascal_case_aliases( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: """The PS-shim translation produces ``--template-path`` / ``--snapshot-name``.""" base = tmp_path / "build-vms" backend = _FakeBackend() monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke( cli, [ "vm", "new", "--template-path", str(fake_template), "--snapshot-name", "Custom", "--clone-base-dir", str(base), "--job-id", "alias", ], ) assert result.exit_code == 0, result.output assert backend.calls[0]["snapshot"] == "Custom" def test_vm_new_backend_unavailable( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: from ci_orchestrator.backends.errors import BackendNotAvailable base = tmp_path / "build-vms" def _raise(_v: Any) -> Any: raise BackendNotAvailable("no vmrun") monkeypatch.setattr(vm_module, "_make_backend", _raise) result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(fake_template), "--clone-base-dir", str(base), "--job-id", "novm", ], ) assert result.exit_code != 0 assert "vmrun unavailable" in result.output def test_vm_new_partial_clone_dir_cleaned( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: """Backend creates the partial dir then fails -> dir is removed.""" base = tmp_path / "build-vms" class _B: def clone_linked( self, template: str, snapshot: str, name: str, destination: str | None = None, ) -> Any: assert destination is not None Path(destination).parent.mkdir(parents=True, exist_ok=True) (Path(destination).parent / "stale.txt").write_text("x") raise BackendOperationFailed("clone", 1, "boom") monkeypatch.setattr(vm_module, "_make_backend", lambda _v: _B()) result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(fake_template), "--clone-base-dir", str(base), "--job-id", "partial", ], ) assert result.exit_code != 0 assert "clone failed" in result.output assert not any(p.is_dir() and "partial" in p.name for p in base.iterdir()) def test_vm_new_backend_success_but_missing_vmx( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: """Backend reports success but VMX file isn't there -> ClickException.""" base = tmp_path / "build-vms" class _B: def clone_linked( self, template: str, snapshot: str, name: str, destination: str | None = None, ) -> VmHandle: return VmHandle(identifier=str(destination), name=name) monkeypatch.setattr(vm_module, "_make_backend", lambda _v: _B()) result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(fake_template), "--clone-base-dir", str(base), "--job-id", "ghost", ], ) assert result.exit_code != 0 assert "clone VMX is missing" in result.output def test_vm_new_creates_base_dir( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: base = tmp_path / "missing-base" backend = _FakeBackend() monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke( cli, [ "vm", "new", "--template", str(fake_template), "--clone-base-dir", str(base), "--job-id", "bd", ], ) assert result.exit_code == 0, result.output assert base.is_dir()