gabriel / muse public
test_core_ci.py python
332 lines 11.3 KB
Raw
1 """Tests for muse.core.ci — CI gate runner and .muse/ci.toml loading.
2
3 Coverage:
4 - load_ci_config returns DEFAULT_CONFIG when file missing.
5 - load_ci_config parses a valid .muse/ci.toml correctly.
6 - load_ci_config raises ValueError on invalid TOML.
7 - load_ci_config raises ValueError when gate command is not a list of strings.
8 - _parse_gate validates required 'command' field.
9 - _parse_settings applies defaults for missing fields.
10 - run_ci executes all gates and returns CiRunResult.
11 - run_ci marks overall as passed when all required gates pass.
12 - run_ci marks overall as failed when any required gate fails.
13 - run_ci skips non-required gate failure in overall result.
14 - GateResult has all required fields.
15 - timeout enforcement marks gate as timed_out.
16 """
17
18 from __future__ import annotations
19
20 import pathlib
21 import sys
22
23 import pytest
24
25 from muse.core.ci import (
26 CiConfig,
27 CiGate,
28 CiRunResult,
29 CiSettings,
30 GateResult,
31 _DEFAULT_CONFIG,
32 _parse_gate,
33 _parse_settings,
34 load_ci_config,
35 run_ci,
36 )
37 from muse.core.types import MsgpackValue
38 from muse.core.paths import muse_dir
39
40
41 # ---------------------------------------------------------------------------
42 # Unit tests — _parse_gate
43 # ---------------------------------------------------------------------------
44
45
46 class TestParseGate:
47 def test_minimal_gate(self) -> None:
48 raw: MsgpackValue = {"command": ["echo", "hi"], "name": "echo"}
49 gate = _parse_gate(raw, 0)
50 assert gate["name"] == "echo"
51 assert gate["command"] == ["echo", "hi"]
52 assert gate["required"] is True
53 assert gate["timeout_s"] == 0.0
54
55 def test_full_gate(self) -> None:
56 raw: MsgpackValue = {
57 "name": "tests",
58 "command": [sys.executable, "-m", "pytest", "tests/"],
59 "timeout_s": 300.0,
60 "required": False,
61 }
62 gate = _parse_gate(raw, 0)
63 assert gate["name"] == "tests"
64 assert gate["timeout_s"] == 300.0
65 assert gate["required"] is False
66
67 def test_missing_command_raises(self) -> None:
68 raw: MsgpackValue = {"name": "bad"}
69 with pytest.raises(ValueError, match="'command'"):
70 _parse_gate(raw, 0)
71
72 def test_empty_command_raises(self) -> None:
73 raw: MsgpackValue = {"name": "empty", "command": []}
74 with pytest.raises(ValueError, match="must not be empty"):
75 _parse_gate(raw, 0)
76
77 def test_non_string_command_raises(self) -> None:
78 raw: MsgpackValue = {"name": "bad", "command": [123, "pytest"]}
79 with pytest.raises(ValueError, match="list of strings"):
80 _parse_gate(raw, 0)
81
82 def test_non_dict_raises(self) -> None:
83 with pytest.raises(ValueError, match="must be a TOML table"):
84 non_dict: MsgpackValue = "not a dict"
85 _parse_gate(non_dict, 0)
86
87 def test_default_name_when_missing(self) -> None:
88 raw: MsgpackValue = {"command": ["echo", "hi"]}
89 gate = _parse_gate(raw, 5)
90 assert gate["name"] == "gate-5"
91
92
93 # ---------------------------------------------------------------------------
94 # Unit tests — _parse_settings
95 # ---------------------------------------------------------------------------
96
97
98 class TestParseSettings:
99 def test_defaults_on_empty(self) -> None:
100 settings = _parse_settings({})
101 assert settings["test_budget_s"] == 300.0
102 assert settings["workers"] == 1
103 assert settings["env_allowlist"] == []
104
105 def test_parses_values(self) -> None:
106 raw: MsgpackValue = {
107 "test_budget_s": 120.0,
108 "workers": 4,
109 "env_allowlist": ["CI", "MY_VAR"],
110 }
111 settings = _parse_settings(raw)
112 assert settings["test_budget_s"] == 120.0
113 assert settings["workers"] == 4
114 assert settings["env_allowlist"] == ["CI", "MY_VAR"]
115
116 def test_non_dict_returns_defaults(self) -> None:
117 settings = _parse_settings(None)
118 assert settings["workers"] == 1
119
120
121 # ---------------------------------------------------------------------------
122 # Integration tests — load_ci_config
123 # ---------------------------------------------------------------------------
124
125
126 class TestLoadCiConfig:
127 def test_missing_file_returns_default(self, tmp_path: pathlib.Path) -> None:
128 muse_dir(tmp_path).mkdir()
129 config = load_ci_config(tmp_path)
130 assert config["version"] == _DEFAULT_CONFIG["version"]
131 assert len(config["gates"]) == len(_DEFAULT_CONFIG["gates"])
132
133 def test_parses_valid_toml(self, tmp_path: pathlib.Path) -> None:
134 dot_muse = muse_dir(tmp_path)
135 dot_muse.mkdir()
136 toml_content = """\
137 version = 1
138
139 [settings]
140 test_budget_s = 60
141 workers = 2
142 env_allowlist = ["CI"]
143
144 [[gate]]
145 name = "echo-check"
146 command = ["echo", "hello"]
147 timeout_s = 5
148 required = true
149 """
150 (dot_muse / "ci.toml").write_text(toml_content)
151 config = load_ci_config(tmp_path)
152 assert config["version"] == 1
153 assert config["settings"]["workers"] == 2
154 assert config["settings"]["test_budget_s"] == 60.0
155 assert len(config["gates"]) == 1
156 gate = config["gates"][0]
157 assert gate["name"] == "echo-check"
158 assert gate["command"] == ["echo", "hello"]
159 assert gate["timeout_s"] == 5.0
160 assert gate["required"] is True
161
162 def test_invalid_toml_raises(self, tmp_path: pathlib.Path) -> None:
163 dot_muse = muse_dir(tmp_path)
164 dot_muse.mkdir()
165 (dot_muse / "ci.toml").write_text("this is [ not valid toml !!!{{}}")
166 with pytest.raises(ValueError, match="Failed to parse"):
167 load_ci_config(tmp_path)
168
169 def test_non_table_toml_raises(self, tmp_path: pathlib.Path) -> None:
170 """A TOML file that doesn't produce a dict at top level raises."""
171 # Note: TOML always produces a dict at top level; this tests the
172 # error path for defensive code.
173 dot_muse = muse_dir(tmp_path)
174 dot_muse.mkdir()
175 # Write syntactically valid TOML (always a dict, so test the guard
176 # is unreachable, but let's ensure we parse it without error).
177 (dot_muse / "ci.toml").write_text("version = 1\n")
178 config = load_ci_config(tmp_path)
179 assert config["version"] == 1
180
181
182 # ---------------------------------------------------------------------------
183 # Integration tests — run_ci
184 # ---------------------------------------------------------------------------
185
186
187 class TestRunCi:
188 def _make_config(
189 self, gates: list[CiGate], settings: CiSettings | None = None
190 ) -> CiConfig:
191 return CiConfig(
192 version=1,
193 settings=settings or CiSettings(
194 test_budget_s=300.0,
195 workers=1,
196 env_allowlist=[],
197 ),
198 gates=gates,
199 )
200
201 def test_all_gates_pass(self, tmp_path: pathlib.Path) -> None:
202 config = self._make_config([
203 CiGate(
204 name="echo1",
205 command=["echo", "pass"],
206 timeout_s=5.0,
207 required=True,
208 ),
209 ])
210 result = run_ci(tmp_path, config)
211 assert result["passed"] is True
212 assert len(result["gates"]) == 1
213 assert result["gates"][0]["passed"] is True
214
215 def test_required_gate_failure_marks_overall_failed(
216 self, tmp_path: pathlib.Path
217 ) -> None:
218 config = self._make_config([
219 CiGate(
220 name="fail-gate",
221 command=[sys.executable, "-c", "raise SystemExit(1)"],
222 timeout_s=5.0,
223 required=True,
224 ),
225 ])
226 result = run_ci(tmp_path, config)
227 assert result["passed"] is False
228 assert result["gates"][0]["passed"] is False
229
230 def test_non_required_gate_failure_does_not_fail_overall(
231 self, tmp_path: pathlib.Path
232 ) -> None:
233 config = self._make_config([
234 CiGate(
235 name="pass-gate",
236 command=["echo", "ok"],
237 timeout_s=5.0,
238 required=True,
239 ),
240 CiGate(
241 name="warn-gate",
242 command=[sys.executable, "-c", "raise SystemExit(1)"],
243 timeout_s=5.0,
244 required=False,
245 ),
246 ])
247 result = run_ci(tmp_path, config)
248 assert result["passed"] is True
249 warn = next(g for g in result["gates"] if g["name"] == "warn-gate")
250 assert warn["passed"] is False
251 assert "warning" in warn
252
253 def test_gate_result_structure(self, tmp_path: pathlib.Path) -> None:
254 config = self._make_config([
255 CiGate(
256 name="echo",
257 command=["echo", "hello"],
258 timeout_s=5.0,
259 required=True,
260 ),
261 ])
262 result = run_ci(tmp_path, config)
263 g = result["gates"][0]
264 assert isinstance(g["name"], str)
265 assert isinstance(g["command"], list)
266 assert isinstance(g["exit_code"], int)
267 assert isinstance(g["duration_ms"], float)
268 assert isinstance(g["passed"], bool)
269 assert isinstance(g["timed_out"], bool)
270 assert isinstance(g["stdout"], str)
271 assert isinstance(g["stderr"], str)
272
273 def test_ci_run_result_structure(self, tmp_path: pathlib.Path) -> None:
274 config = self._make_config([
275 CiGate(
276 name="echo",
277 command=["echo", "hi"],
278 timeout_s=5.0,
279 required=True,
280 ),
281 ])
282 result = run_ci(tmp_path, config)
283 assert isinstance(result["passed"], bool)
284 assert isinstance(result["gates"], list)
285 assert isinstance(result["total_duration_ms"], float)
286 assert isinstance(result["timestamp"], str)
287 assert result["total_duration_ms"] >= 0.0
288
289 def test_timeout_marks_timed_out(self, tmp_path: pathlib.Path) -> None:
290 """A gate that exceeds timeout_s is marked timed_out."""
291 import time
292 config = self._make_config([
293 CiGate(
294 name="slow",
295 command=[sys.executable, "-c", "import time; time.sleep(30)"],
296 timeout_s=0.1,
297 required=True,
298 ),
299 ])
300 result = run_ci(tmp_path, config)
301 slow_gate = result["gates"][0]
302 assert slow_gate["timed_out"] is True
303 assert result["passed"] is False
304
305 def test_empty_gates_passes(self, tmp_path: pathlib.Path) -> None:
306 """CI with no gates is trivially passing."""
307 config = self._make_config([])
308 result = run_ci(tmp_path, config)
309 assert result["passed"] is True
310 assert result["gates"] == []
311
312 def test_multiple_gates_all_executed(self, tmp_path: pathlib.Path) -> None:
313 """All gates run even when earlier ones fail (full picture collection)."""
314 config = self._make_config([
315 CiGate(
316 name="gate-1",
317 command=[sys.executable, "-c", "raise SystemExit(1)"],
318 timeout_s=5.0,
319 required=True,
320 ),
321 CiGate(
322 name="gate-2",
323 command=["echo", "still runs"],
324 timeout_s=5.0,
325 required=True,
326 ),
327 ])
328 result = run_ci(tmp_path, config)
329 assert len(result["gates"]) == 2
330 names = {g["name"] for g in result["gates"]}
331 assert "gate-1" in names
332 assert "gate-2" in names
File History 1 commit