"""Tests for ``ci_orchestrator smoke`` (Phase C1 scaffolding + C4 behaviour).""" from __future__ import annotations import json from pathlib import Path from typing import Any import pytest from click.testing import CliRunner import ci_orchestrator.commands.smoke as smoke_module from ci_orchestrator.__main__ import cli from ci_orchestrator.config import BackendConfig, Config, Paths # ──────────────────────────────────────────────────────── C1 --help smoke def test_smoke_help_lists_run() -> None: result = CliRunner().invoke(cli, ["smoke", "--help"]) assert result.exit_code == 0, result.output assert "run" in result.output def test_smoke_run_help() -> None: result = CliRunner().invoke(cli, ["smoke", "run", "--help"]) assert result.exit_code == 0, result.output assert "--guest-os" in result.output # ──────────────────────────────────────────────────────────── helpers def _patch_config(monkeypatch: pytest.MonkeyPatch, artifacts: Path) -> None: paths = Paths( root=artifacts.parent, templates=artifacts.parent / "templates", build_vms=artifacts.parent / "build-vms", artifacts=artifacts, keys=artifacts.parent / "keys", ) cfg = Config(paths=paths, backend=BackendConfig(), ip_pool=None) monkeypatch.setattr(smoke_module, "load_config", lambda: cfg) def _patch_job( monkeypatch: pytest.MonkeyPatch, *, artifacts: Path, make_artifact: bool = True, raises: bool = False, ) -> dict[str, list[Any]]: """Replace the in-process ``job`` command with a recording fake.""" calls: dict[str, list[Any]] = {"job": []} def _fake_callback(**kwargs: Any) -> None: calls["job"].append(kwargs) if make_artifact: job_dir = artifacts / kwargs["job_id"] job_dir.mkdir(parents=True, exist_ok=True) (job_dir / "smoke.txt").write_text("smoke-ok", encoding="utf-8") if raises: import click raise click.ClickException("clone failed: boom") # ``job_command`` is a click.Command; patch its callback. monkeypatch.setattr(smoke_module.job_command, "callback", _fake_callback) return calls def _read_events(jsonl_path: Path) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] for line in jsonl_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if line: out.append(json.loads(line)) return out # ──────────────────────────────────────────────────────── C4 behaviour def test_smoke_run_linux_happy_path( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) calls = _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, ["smoke", "run", "--guest-os", "linux", "--job-id", "smoke-1"] ) assert result.exit_code == 0, result.output assert "PASSED" in result.output # The job ran once with the Linux no-op marker preset. assert len(calls["job"]) == 1 kw = calls["job"][0] assert kw["guest_os"] == "linux" assert kw["job_id"] == "smoke-1" assert "smoke.txt" in kw["build_command"] assert kw["guest_artifact_source"] == "/opt/ci/output" # Artifact dir was created and the success event is present. assert (artifacts / "smoke-1").is_dir() events = _read_events(tmp_path / "logs" / "smoke-1" / "invoke-ci.jsonl") assert any(e["phase"] == "job" and e["status"] == "start" for e in events) assert any(e["phase"] == "job" and e["status"] == "success" for e in events) def test_smoke_run_windows_uses_windows_preset( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) calls = _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, ["smoke", "run", "--guest-os", "windows", "--job-id", "smoke-win"] ) assert result.exit_code == 0, result.output kw = calls["job"][0] assert kw["guest_os"] == "windows" assert kw["guest_artifact_source"] == "C:\\CI\\output" assert "C:\\CI\\output" in kw["build_command"] def test_smoke_run_failure_when_job_raises( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) _patch_job(monkeypatch, artifacts=artifacts, make_artifact=False, raises=True) result = CliRunner().invoke( cli, ["smoke", "run", "--guest-os", "linux", "--job-id", "smoke-fail"] ) assert result.exit_code != 0 assert "FAILED" in result.output # A failure event was logged and there is no success event. events = _read_events(tmp_path / "logs" / "smoke-fail" / "invoke-ci.jsonl") assert any(e["status"] == "failure" for e in events) assert not any(e["status"] == "success" for e in events) def test_smoke_run_fails_when_artifact_missing( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) # Job "succeeds" but produces no artifact dir → smoke must still fail. _patch_job(monkeypatch, artifacts=artifacts, make_artifact=False) result = CliRunner().invoke( cli, ["smoke", "run", "--guest-os", "linux", "--job-id", "smoke-noart"] ) assert result.exit_code != 0 assert "artifact dir not found" in result.output def test_smoke_run_preset_ns7zip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) calls = _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, ["smoke", "run", "--guest-os", "linux", "--preset", "ns7zip", "--job-id", "p1"], ) assert result.exit_code == 0, result.output kw = calls["job"][0] assert "build_plugin.py" in kw["build_command"] assert "7zip-version" in kw["build_command"] assert kw["guest_artifact_source"] == "plugins" def test_smoke_run_preset_nsinnounp( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) calls = _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, ["smoke", "run", "--preset", "nsinnounp", "--job-id", "p2"], ) assert result.exit_code == 0, result.output kw = calls["job"][0] assert kw["build_command"] == "python3 build_plugin.py --final --dist-dir dist" assert kw["guest_artifact_source"] == "dist" def test_smoke_run_preset_rejected_on_windows( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, ["smoke", "run", "--guest-os", "windows", "--preset", "ns7zip"] ) assert result.exit_code != 0 assert "only supported with --guest-os linux" in result.output def test_smoke_run_preset_and_build_command_mutually_exclusive( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, [ "smoke", "run", "--preset", "ns7zip", "--build-command", "echo hi", ], ) assert result.exit_code != 0 assert "mutually exclusive" in result.output def test_smoke_run_custom_build_command( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" _patch_config(monkeypatch, artifacts) calls = _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, [ "smoke", "run", "--guest-os", "linux", "--build-command", "make all", "--job-id", "custom-1", ], ) assert result.exit_code == 0, result.output kw = calls["job"][0] assert kw["build_command"] == "make all" # Custom command keeps the OS-default artifact source. assert kw["guest_artifact_source"] == "/opt/ci/output" def test_has_job_success_tolerates_malformed_jsonl(tmp_path: Path) -> None: """Garbage/missing log lines must not raise and must report no success.""" missing = tmp_path / "nope.jsonl" assert smoke_module._has_job_success(missing) is False jsonl = tmp_path / "log.jsonl" jsonl.write_text( "\n" # blank line "not json\n" # JSONDecodeError "[1, 2, 3]\n" # valid JSON but not a dict '{"phase": "job", "status": "start"}\n', # dict, no success encoding="utf-8", ) assert smoke_module._has_job_success(jsonl) is False jsonl.write_text('{"phase": "job", "status": "success"}\n', encoding="utf-8") assert smoke_module._has_job_success(jsonl) is True def test_smoke_run_default_job_id_and_separate_log_dir( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: artifacts = tmp_path / "artifacts" logs = tmp_path / "logs" _patch_config(monkeypatch, artifacts) calls = _patch_job(monkeypatch, artifacts=artifacts) result = CliRunner().invoke( cli, ["smoke", "run", "--log-dir", str(logs)] ) assert result.exit_code == 0, result.output generated_id = calls["job"][0]["job_id"] assert generated_id.startswith("smoke-") # JSONL written under the override log dir, artifacts under config dir. assert (logs / generated_id / "invoke-ci.jsonl").is_file() assert (artifacts / generated_id).is_dir()