gabriel / muse public
test_maintenance_supercharge.py python
368 lines 14.8 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago
1 """Supercharge tests for ``muse maintenance``.
2
3 Coverage tiers
4 --------------
5 - JSON envelope: status, error, exit_code, duration_ms always present on run/status/schedule
6 - Error payload: exactly {status, error, exit_code} — no prose to stdout in --json mode
7 - OID integrity: verify-objects failure list uses sha256:-prefixed IDs
8 - schedule --json: new mode; emits {status, enabled, period_hours, exit_code}
9 - TypedDicts: _MaintenanceRunJson, _MaintenanceStatusJson, _MaintenanceScheduleJson,
10 _MaintenanceErrorJson exist and are annotated
11 - Docstring: covers status, error, exit_code, duration_ms for all subcommands
12 - No-prose pollution: valid JSON on stdout, no emoji in JSON mode
13 """
14 from __future__ import annotations
15
16 import datetime
17 import argparse
18 import json
19 import pathlib
20 from typing import get_type_hints
21
22 from muse.core.object_store import object_path, write_object
23 from muse.core.ids import hash_commit as compute_commit_id, hash_snapshot as compute_snapshot_id
24 from muse.core.snapshots import (
25 SnapshotRecord,
26 write_snapshot,
27 )
28 from muse.core.types import Manifest, blob_id
29 from muse.core.paths import muse_dir
30 from tests.cli_test_helper import CliRunner, InvokeResult
31
32 runner = CliRunner()
33 _REPO_ID = "maintenance-sg"
34
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40
41
42 def _init_repo(path: pathlib.Path) -> pathlib.Path:
43 dot_muse = muse_dir(path)
44 for d in ("commits", "snapshots", "objects", "refs/heads", "code"):
45 (dot_muse / d).mkdir(parents=True, exist_ok=True)
46 (dot_muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
47 (dot_muse / "repo.json").write_text(
48 json.dumps({"repo_id": _REPO_ID, "domain": "code"}), encoding="utf-8"
49 )
50 return path
51
52
53 def _write_obj(repo: pathlib.Path, content: bytes) -> str:
54 oid = blob_id(content)
55 write_object(repo, oid, content)
56 return oid
57
58
59 def _invoke(repo: pathlib.Path, *args: str) -> InvokeResult:
60 from muse.cli.app import main as cli
61 return runner.invoke(cli, list(args), env={"MUSE_REPO_ROOT": str(repo)})
62
63
64 # ---------------------------------------------------------------------------
65 # run --json envelope
66 # ---------------------------------------------------------------------------
67
68 class TestRunJsonEnvelope:
69 """``maintenance run --json`` envelope has all required fields."""
70
71 _REQUIRED = {"status", "error", "tasks_run", "results", "dry_run", "duration_ms", "exit_code"}
72
73 def test_all_required_keys_present(self, tmp_path: pathlib.Path) -> None:
74 repo = _init_repo(tmp_path)
75 r = _invoke(repo, "maintenance", "run", "--json")
76 assert r.exit_code == 0
77 d = json.loads(r.output)
78 missing = self._REQUIRED - d.keys()
79 assert not missing, f"Missing keys: {missing}"
80
81 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
82 repo = _init_repo(tmp_path)
83 r = _invoke(repo, "maintenance", "run", "--json")
84 assert json.loads(r.output)["status"] == "ok"
85
86 def test_error_empty_on_success(self, tmp_path: pathlib.Path) -> None:
87 repo = _init_repo(tmp_path)
88 r = _invoke(repo, "maintenance", "run", "--json")
89 assert json.loads(r.output)["error"] == ""
90
91 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
92 repo = _init_repo(tmp_path)
93 r = _invoke(repo, "maintenance", "run", "--json")
94 assert json.loads(r.output)["exit_code"] == 0
95
96 def test_duration_ms_is_nonneg_float(self, tmp_path: pathlib.Path) -> None:
97 repo = _init_repo(tmp_path)
98 r = _invoke(repo, "maintenance", "run", "--json")
99 d = json.loads(r.output)
100 assert isinstance(d["duration_ms"], float)
101 assert d["duration_ms"] >= 0.0
102
103 def test_no_elapsed_ms_key(self, tmp_path: pathlib.Path) -> None:
104 """Deprecated elapsed_ms replaced by duration_ms for consistency."""
105 repo = _init_repo(tmp_path)
106 r = _invoke(repo, "maintenance", "run", "--json")
107 d = json.loads(r.output)
108 assert "elapsed_ms" not in d
109
110 def test_dry_run_flag_reflected(self, tmp_path: pathlib.Path) -> None:
111 repo = _init_repo(tmp_path)
112 r = _invoke(repo, "maintenance", "run", "--dry-run", "--json")
113 d = json.loads(r.output)
114 assert d["dry_run"] is True
115
116 def test_tasks_run_is_list(self, tmp_path: pathlib.Path) -> None:
117 repo = _init_repo(tmp_path)
118 r = _invoke(repo, "maintenance", "run", "--task", "gc", "--json")
119 d = json.loads(r.output)
120 assert isinstance(d["tasks_run"], list)
121 assert "gc" in d["tasks_run"]
122
123
124 # ---------------------------------------------------------------------------
125 # status --json envelope
126 # ---------------------------------------------------------------------------
127
128 class TestStatusJsonEnvelope:
129 """``maintenance status --json`` envelope has all required fields."""
130
131 _REQUIRED = {"status", "error", "enabled", "period_hours", "tasks", "last_run", "exit_code"}
132
133 def test_all_required_keys_present(self, tmp_path: pathlib.Path) -> None:
134 repo = _init_repo(tmp_path)
135 r = _invoke(repo, "maintenance", "status", "--json")
136 assert r.exit_code == 0
137 d = json.loads(r.output)
138 missing = self._REQUIRED - d.keys()
139 assert not missing, f"Missing keys: {missing}"
140
141 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
142 repo = _init_repo(tmp_path)
143 r = _invoke(repo, "maintenance", "status", "--json")
144 assert json.loads(r.output)["status"] == "ok"
145
146 def test_error_empty_on_success(self, tmp_path: pathlib.Path) -> None:
147 repo = _init_repo(tmp_path)
148 r = _invoke(repo, "maintenance", "status", "--json")
149 assert json.loads(r.output)["error"] == ""
150
151 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
152 repo = _init_repo(tmp_path)
153 r = _invoke(repo, "maintenance", "status", "--json")
154 assert json.loads(r.output)["exit_code"] == 0
155
156
157 # ---------------------------------------------------------------------------
158 # schedule --json
159 # ---------------------------------------------------------------------------
160
161 class TestScheduleJson:
162 """``maintenance schedule --json`` emits a structured result."""
163
164 _REQUIRED = {"status", "error", "enabled", "period_hours", "exit_code"}
165
166 def test_schedule_json_flag_accepted(self, tmp_path: pathlib.Path) -> None:
167 repo = _init_repo(tmp_path)
168 r = _invoke(repo, "maintenance", "schedule", "--period-hours", "48", "--json")
169 assert r.exit_code == 0
170
171 def test_schedule_json_all_required_keys(self, tmp_path: pathlib.Path) -> None:
172 repo = _init_repo(tmp_path)
173 r = _invoke(repo, "maintenance", "schedule", "--json")
174 assert r.exit_code == 0
175 d = json.loads(r.output)
176 missing = self._REQUIRED - d.keys()
177 assert not missing, f"Missing keys: {missing}"
178
179 def test_schedule_json_status_ok(self, tmp_path: pathlib.Path) -> None:
180 repo = _init_repo(tmp_path)
181 r = _invoke(repo, "maintenance", "schedule", "--json")
182 assert json.loads(r.output)["status"] == "ok"
183
184 def test_schedule_json_reflects_period_hours(self, tmp_path: pathlib.Path) -> None:
185 repo = _init_repo(tmp_path)
186 r = _invoke(repo, "maintenance", "schedule", "--period-hours", "72", "--json")
187 d = json.loads(r.output)
188 assert d["period_hours"] == 72
189
190 def test_schedule_json_reflects_enabled_true(self, tmp_path: pathlib.Path) -> None:
191 repo = _init_repo(tmp_path)
192 r = _invoke(repo, "maintenance", "schedule", "--enable", "--json")
193 d = json.loads(r.output)
194 assert d["enabled"] is True
195
196 def test_schedule_json_reflects_enabled_false(self, tmp_path: pathlib.Path) -> None:
197 repo = _init_repo(tmp_path)
198 r = _invoke(repo, "maintenance", "schedule", "--disable", "--json")
199 d = json.loads(r.output)
200 assert d["enabled"] is False
201
202 def test_schedule_json_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
203 repo = _init_repo(tmp_path)
204 r = _invoke(repo, "maintenance", "schedule", "--json")
205 assert json.loads(r.output)["exit_code"] == 0
206
207
208 # ---------------------------------------------------------------------------
209 # OID integrity — verify-objects failures list
210 # ---------------------------------------------------------------------------
211
212 class TestVerifyObjectsOidIntegrity:
213 """Failure OIDs in verify-objects results carry the sha256: prefix."""
214
215 def test_failures_list_uses_sha256_prefix(self, tmp_path: pathlib.Path) -> None:
216 """When an object is corrupt, its ID in the failures list is sha256:-prefixed."""
217 repo = _init_repo(tmp_path)
218 oid = _write_obj(repo, b"original content")
219 obj_path = object_path(repo, oid)
220 obj_path.chmod(0o644)
221 obj_path.write_bytes(b"corrupted data")
222
223 r = _invoke(repo, "maintenance", "run", "--task", "verify-objects", "--json")
224 d = json.loads(r.output)
225 failures = d["results"]["verify-objects"]["failures"]
226 assert failures, "Expected at least one failure entry"
227 for fid in failures:
228 assert fid.startswith("sha256:"), f"Failure OID not prefixed: {fid!r}"
229
230 def test_clean_store_failures_list_empty(self, tmp_path: pathlib.Path) -> None:
231 repo = _init_repo(tmp_path)
232 for i in range(3):
233 _write_obj(repo, f"clean-{i}".encode())
234 r = _invoke(repo, "maintenance", "run", "--task", "verify-objects", "--json")
235 d = json.loads(r.output)
236 assert d["results"]["verify-objects"]["failures"] == []
237
238
239 # ---------------------------------------------------------------------------
240 # No-prose pollution
241 # ---------------------------------------------------------------------------
242
243 class TestNoProsePollution:
244 def test_run_json_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
245 repo = _init_repo(tmp_path)
246 r = _invoke(repo, "maintenance", "run", "--json")
247 json.loads(r.output) # must not raise
248
249 def test_status_json_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
250 repo = _init_repo(tmp_path)
251 r = _invoke(repo, "maintenance", "status", "--json")
252 json.loads(r.output) # must not raise
253
254 def test_schedule_json_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
255 repo = _init_repo(tmp_path)
256 r = _invoke(repo, "maintenance", "schedule", "--json")
257 json.loads(r.output) # must not raise
258
259 def test_no_emoji_in_run_json_stdout(self, tmp_path: pathlib.Path) -> None:
260 repo = _init_repo(tmp_path)
261 r = _invoke(repo, "maintenance", "run", "--json")
262 assert "❌" not in r.output
263 assert "✅" not in r.output
264
265 def test_no_traceback_in_run_json(self, tmp_path: pathlib.Path) -> None:
266 repo = _init_repo(tmp_path)
267 r = _invoke(repo, "maintenance", "run", "--json")
268 assert "Traceback" not in r.output
269
270
271 # ---------------------------------------------------------------------------
272 # TypedDicts
273 # ---------------------------------------------------------------------------
274
275 class TestTypedDicts:
276 def test_maintenance_run_json_exists(self) -> None:
277 from muse.cli.commands.maintenance import _MaintenanceRunJson
278 assert _MaintenanceRunJson is not None
279
280 def test_maintenance_status_json_exists(self) -> None:
281 from muse.cli.commands.maintenance import _MaintenanceStatusJson
282 assert _MaintenanceStatusJson is not None
283
284 def test_maintenance_schedule_json_exists(self) -> None:
285 from muse.cli.commands.maintenance import _MaintenanceScheduleJson
286 assert _MaintenanceScheduleJson is not None
287
288 def test_maintenance_error_json_exists(self) -> None:
289 from muse.cli.commands.maintenance import _MaintenanceErrorJson
290 assert _MaintenanceErrorJson is not None
291
292 def test_run_json_has_required_annotations(self) -> None:
293 from muse.cli.commands.maintenance import _MaintenanceRunJson
294 hints = get_type_hints(_MaintenanceRunJson)
295 for field in ("status", "error", "duration_ms", "exit_code"):
296 assert field in hints, f"Missing annotation: {field!r}"
297
298 def test_status_json_has_required_annotations(self) -> None:
299 from muse.cli.commands.maintenance import _MaintenanceStatusJson
300 hints = get_type_hints(_MaintenanceStatusJson)
301 for field in ("status", "error", "exit_code"):
302 assert field in hints, f"Missing annotation: {field!r}"
303
304 def test_schedule_json_has_required_annotations(self) -> None:
305 from muse.cli.commands.maintenance import _MaintenanceScheduleJson
306 hints = get_type_hints(_MaintenanceScheduleJson)
307 for field in ("status", "error", "enabled", "period_hours", "exit_code"):
308 assert field in hints, f"Missing annotation: {field!r}"
309
310
311 # ---------------------------------------------------------------------------
312 # Docstring coverage
313 # ---------------------------------------------------------------------------
314
315 class TestDocstring:
316 def _doc(self) -> str:
317 import muse.cli.commands.maintenance as mod
318 return mod.__doc__ or ""
319
320 def test_docstring_documents_status(self) -> None:
321 assert "status" in self._doc()
322
323 def test_docstring_documents_error(self) -> None:
324 assert "error" in self._doc()
325
326 def test_docstring_documents_exit_code(self) -> None:
327 assert "exit_code" in self._doc()
328
329 def test_docstring_documents_duration_ms(self) -> None:
330 assert "duration_ms" in self._doc()
331
332 def test_docstring_documents_error_schema(self) -> None:
333 doc = self._doc()
334 assert "error" in doc and "exit_code" in doc
335
336
337 # ---------------------------------------------------------------------------
338 # TestRegisterFlags — argparse-level verification
339 # ---------------------------------------------------------------------------
340
341
342 class TestRegisterFlags:
343 """Verify that register() wires --json / -j correctly."""
344
345 def _make_parser(self) -> "argparse.ArgumentParser":
346 import argparse
347 from muse.cli.commands.maintenance import register
348 ap = argparse.ArgumentParser()
349 subs = ap.add_subparsers()
350 register(subs)
351 return ap
352
353 def test_json_flag_long(self) -> None:
354 ns = self._make_parser().parse_args(["maintenance", "run", "--json"])
355 assert ns.json_out is True
356
357 def test_j_alias(self) -> None:
358 ns = self._make_parser().parse_args(["maintenance", "run", "-j"])
359 assert ns.json_out is True
360
361 def test_default_is_text(self) -> None:
362 ns = self._make_parser().parse_args(["maintenance", "run"])
363 assert ns.json_out is False
364
365 def test_dest_is_json_out(self) -> None:
366 ns = self._make_parser().parse_args(["maintenance", "run", "-j"])
367 assert hasattr(ns, "json_out")
368 assert not hasattr(ns, "fmt")
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago