"""Tests for muse.core.ci — CI gate runner and .muse/ci.toml loading. Coverage: - load_ci_config returns DEFAULT_CONFIG when file missing. - load_ci_config parses a valid .muse/ci.toml correctly. - load_ci_config raises ValueError on invalid TOML. - load_ci_config raises ValueError when gate command is not a list of strings. - _parse_gate validates required 'command' field. - _parse_settings applies defaults for missing fields. - run_ci executes all gates and returns CiRunResult. - run_ci marks overall as passed when all required gates pass. - run_ci marks overall as failed when any required gate fails. - run_ci skips non-required gate failure in overall result. - GateResult has all required fields. - timeout enforcement marks gate as timed_out. """ from __future__ import annotations import pathlib import sys import pytest from muse.core.ci import ( CiConfig, CiGate, CiRunResult, CiSettings, GateResult, _DEFAULT_CONFIG, _parse_gate, _parse_settings, load_ci_config, run_ci, ) from muse.core.types import MsgpackValue from muse.core.paths import muse_dir # --------------------------------------------------------------------------- # Unit tests — _parse_gate # --------------------------------------------------------------------------- class TestParseGate: def test_minimal_gate(self) -> None: raw: MsgpackValue = {"command": ["echo", "hi"], "name": "echo"} gate = _parse_gate(raw, 0) assert gate["name"] == "echo" assert gate["command"] == ["echo", "hi"] assert gate["required"] is True assert gate["timeout_s"] == 0.0 def test_full_gate(self) -> None: raw: MsgpackValue = { "name": "tests", "command": [sys.executable, "-m", "pytest", "tests/"], "timeout_s": 300.0, "required": False, } gate = _parse_gate(raw, 0) assert gate["name"] == "tests" assert gate["timeout_s"] == 300.0 assert gate["required"] is False def test_missing_command_raises(self) -> None: raw: MsgpackValue = {"name": "bad"} with pytest.raises(ValueError, match="'command'"): _parse_gate(raw, 0) def test_empty_command_raises(self) -> None: raw: MsgpackValue = {"name": "empty", "command": []} with pytest.raises(ValueError, match="must not be empty"): _parse_gate(raw, 0) def test_non_string_command_raises(self) -> None: raw: MsgpackValue = {"name": "bad", "command": [123, "pytest"]} with pytest.raises(ValueError, match="list of strings"): _parse_gate(raw, 0) def test_non_dict_raises(self) -> None: with pytest.raises(ValueError, match="must be a TOML table"): non_dict: MsgpackValue = "not a dict" _parse_gate(non_dict, 0) def test_default_name_when_missing(self) -> None: raw: MsgpackValue = {"command": ["echo", "hi"]} gate = _parse_gate(raw, 5) assert gate["name"] == "gate-5" # --------------------------------------------------------------------------- # Unit tests — _parse_settings # --------------------------------------------------------------------------- class TestParseSettings: def test_defaults_on_empty(self) -> None: settings = _parse_settings({}) assert settings["test_budget_s"] == 300.0 assert settings["workers"] == 1 assert settings["env_allowlist"] == [] def test_parses_values(self) -> None: raw: MsgpackValue = { "test_budget_s": 120.0, "workers": 4, "env_allowlist": ["CI", "MY_VAR"], } settings = _parse_settings(raw) assert settings["test_budget_s"] == 120.0 assert settings["workers"] == 4 assert settings["env_allowlist"] == ["CI", "MY_VAR"] def test_non_dict_returns_defaults(self) -> None: settings = _parse_settings(None) assert settings["workers"] == 1 # --------------------------------------------------------------------------- # Integration tests — load_ci_config # --------------------------------------------------------------------------- class TestLoadCiConfig: def test_missing_file_returns_default(self, tmp_path: pathlib.Path) -> None: muse_dir(tmp_path).mkdir() config = load_ci_config(tmp_path) assert config["version"] == _DEFAULT_CONFIG["version"] assert len(config["gates"]) == len(_DEFAULT_CONFIG["gates"]) def test_parses_valid_toml(self, tmp_path: pathlib.Path) -> None: dot_muse = muse_dir(tmp_path) dot_muse.mkdir() toml_content = """\ version = 1 [settings] test_budget_s = 60 workers = 2 env_allowlist = ["CI"] [[gate]] name = "echo-check" command = ["echo", "hello"] timeout_s = 5 required = true """ (dot_muse / "ci.toml").write_text(toml_content) config = load_ci_config(tmp_path) assert config["version"] == 1 assert config["settings"]["workers"] == 2 assert config["settings"]["test_budget_s"] == 60.0 assert len(config["gates"]) == 1 gate = config["gates"][0] assert gate["name"] == "echo-check" assert gate["command"] == ["echo", "hello"] assert gate["timeout_s"] == 5.0 assert gate["required"] is True def test_invalid_toml_raises(self, tmp_path: pathlib.Path) -> None: dot_muse = muse_dir(tmp_path) dot_muse.mkdir() (dot_muse / "ci.toml").write_text("this is [ not valid toml !!!{{}}") with pytest.raises(ValueError, match="Failed to parse"): load_ci_config(tmp_path) def test_non_table_toml_raises(self, tmp_path: pathlib.Path) -> None: """A TOML file that doesn't produce a dict at top level raises.""" # Note: TOML always produces a dict at top level; this tests the # error path for defensive code. dot_muse = muse_dir(tmp_path) dot_muse.mkdir() # Write syntactically valid TOML (always a dict, so test the guard # is unreachable, but let's ensure we parse it without error). (dot_muse / "ci.toml").write_text("version = 1\n") config = load_ci_config(tmp_path) assert config["version"] == 1 # --------------------------------------------------------------------------- # Integration tests — run_ci # --------------------------------------------------------------------------- class TestRunCi: def _make_config( self, gates: list[CiGate], settings: CiSettings | None = None ) -> CiConfig: return CiConfig( version=1, settings=settings or CiSettings( test_budget_s=300.0, workers=1, env_allowlist=[], ), gates=gates, ) def test_all_gates_pass(self, tmp_path: pathlib.Path) -> None: config = self._make_config([ CiGate( name="echo1", command=["echo", "pass"], timeout_s=5.0, required=True, ), ]) result = run_ci(tmp_path, config) assert result["passed"] is True assert len(result["gates"]) == 1 assert result["gates"][0]["passed"] is True def test_required_gate_failure_marks_overall_failed( self, tmp_path: pathlib.Path ) -> None: config = self._make_config([ CiGate( name="fail-gate", command=[sys.executable, "-c", "raise SystemExit(1)"], timeout_s=5.0, required=True, ), ]) result = run_ci(tmp_path, config) assert result["passed"] is False assert result["gates"][0]["passed"] is False def test_non_required_gate_failure_does_not_fail_overall( self, tmp_path: pathlib.Path ) -> None: config = self._make_config([ CiGate( name="pass-gate", command=["echo", "ok"], timeout_s=5.0, required=True, ), CiGate( name="warn-gate", command=[sys.executable, "-c", "raise SystemExit(1)"], timeout_s=5.0, required=False, ), ]) result = run_ci(tmp_path, config) assert result["passed"] is True warn = next(g for g in result["gates"] if g["name"] == "warn-gate") assert warn["passed"] is False assert "warning" in warn def test_gate_result_structure(self, tmp_path: pathlib.Path) -> None: config = self._make_config([ CiGate( name="echo", command=["echo", "hello"], timeout_s=5.0, required=True, ), ]) result = run_ci(tmp_path, config) g = result["gates"][0] assert isinstance(g["name"], str) assert isinstance(g["command"], list) assert isinstance(g["exit_code"], int) assert isinstance(g["duration_ms"], float) assert isinstance(g["passed"], bool) assert isinstance(g["timed_out"], bool) assert isinstance(g["stdout"], str) assert isinstance(g["stderr"], str) def test_ci_run_result_structure(self, tmp_path: pathlib.Path) -> None: config = self._make_config([ CiGate( name="echo", command=["echo", "hi"], timeout_s=5.0, required=True, ), ]) result = run_ci(tmp_path, config) assert isinstance(result["passed"], bool) assert isinstance(result["gates"], list) assert isinstance(result["total_duration_ms"], float) assert isinstance(result["timestamp"], str) assert result["total_duration_ms"] >= 0.0 def test_timeout_marks_timed_out(self, tmp_path: pathlib.Path) -> None: """A gate that exceeds timeout_s is marked timed_out.""" import time config = self._make_config([ CiGate( name="slow", command=[sys.executable, "-c", "import time; time.sleep(30)"], timeout_s=0.1, required=True, ), ]) result = run_ci(tmp_path, config) slow_gate = result["gates"][0] assert slow_gate["timed_out"] is True assert result["passed"] is False def test_empty_gates_passes(self, tmp_path: pathlib.Path) -> None: """CI with no gates is trivially passing.""" config = self._make_config([]) result = run_ci(tmp_path, config) assert result["passed"] is True assert result["gates"] == [] def test_multiple_gates_all_executed(self, tmp_path: pathlib.Path) -> None: """All gates run even when earlier ones fail (full picture collection).""" config = self._make_config([ CiGate( name="gate-1", command=[sys.executable, "-c", "raise SystemExit(1)"], timeout_s=5.0, required=True, ), CiGate( name="gate-2", command=["echo", "still runs"], timeout_s=5.0, required=True, ), ]) result = run_ci(tmp_path, config) assert len(result["gates"]) == 2 names = {g["name"] for g in result["gates"]} assert "gate-1" in names assert "gate-2" in names