gabriel / muse public
test_init_supercharge.py python
491 lines 17.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago
1 """Supercharge tests for ``muse init`` JSON agent-readiness.
2
3 Verifies the enhanced JSON schema that makes ``muse init --json`` fully
4 consumable by agents without defensive ``dict.get`` guards:
5
6 {
7 "status": "ok", // always present; "ok" | "error"
8 "error": "", // always present; non-empty on failure
9 "warnings": [], // always present; symlink-skip notices etc.
10 "repo_id": "<uuid>",
11 "branch": "main",
12 "domain": "code",
13 "path": "/abs/.muse",
14 "reinitialised": false,
15 "bare": false,
16 "schema_version": 1,
17 "created_at": "2026-...", // ISO 8601 UTC
18 "duration_ms": 0.0, // wall-clock time for the init
19 "exit_code": 0
20 }
21
22 Error payloads also carry consistent shape:
23
24 {
25 "status": "error",
26 "error": "<message>",
27 "warnings": [],
28 "exit_code": 1
29 }
30 """
31
32 from __future__ import annotations
33
34 import datetime
35 import json
36 import os
37 import pathlib
38
39 import pytest
40
41 from tests.cli_test_helper import CliRunner, InvokeResult
42
43 runner = CliRunner()
44
45
46 # ---------------------------------------------------------------------------
47 # Helpers
48 # ---------------------------------------------------------------------------
49
50
51 def _init(repo: pathlib.Path, *extra_args: str) -> InvokeResult:
52 repo.mkdir(parents=True, exist_ok=True)
53 saved = os.getcwd()
54 try:
55 os.chdir(repo)
56 return runner.invoke(None, ["init", *extra_args])
57 finally:
58 os.chdir(saved)
59
60
61 def _init_json(repo: pathlib.Path, *extra_args: str) -> dict:
62 result = _init(repo, "--json", *extra_args)
63 assert result.exit_code == 0, f"muse init --json failed: {result.output}"
64 return json.loads(result.output)
65
66
67 def _init_json_fail(repo: pathlib.Path, *extra_args: str) -> tuple[dict, int]:
68 """Invoke muse init expecting non-zero exit; return (payload, exit_code)."""
69 result = _init(repo, "--json", *extra_args)
70 assert result.exit_code != 0, f"Expected failure but got exit 0: {result.output}"
71 return json.loads(result.output), result.exit_code
72
73
74 # ---------------------------------------------------------------------------
75 # TestJsonSchemaAgent
76 #
77 # Every field an agent depends on must be present in every success response,
78 # with the correct type, so agents never need ``dict.get`` guards.
79 # ---------------------------------------------------------------------------
80
81
82 class TestJsonSchemaAgent:
83 REQUIRED_KEYS = {
84 "status",
85 "error",
86 "warnings",
87 "repo_id",
88 "branch",
89 "domain",
90 "path",
91 "reinitialised",
92 "bare",
93 "schema_version",
94 "created_at",
95 "duration_ms",
96 "exit_code",
97 }
98
99 def test_all_required_keys_present_on_fresh_init(
100 self, tmp_path: pathlib.Path
101 ) -> None:
102 data = _init_json(tmp_path / "repo")
103 missing = self.REQUIRED_KEYS - set(data)
104 assert not missing, f"Missing keys in init JSON: {missing}"
105
106 def test_all_required_keys_present_on_reinit(
107 self, tmp_path: pathlib.Path
108 ) -> None:
109 repo = tmp_path / "repo"
110 _init(repo)
111 data = _init_json(repo, "--force")
112 missing = self.REQUIRED_KEYS - set(data)
113 assert not missing, f"Missing keys after --force: {missing}"
114
115 def test_all_required_keys_present_on_bare_init(
116 self, tmp_path: pathlib.Path
117 ) -> None:
118 data = _init_json(tmp_path / "repo", "--bare")
119 missing = self.REQUIRED_KEYS - set(data)
120 assert not missing, f"Missing keys in bare init JSON: {missing}"
121
122 def test_no_extra_unknown_keys(self, tmp_path: pathlib.Path) -> None:
123 data = _init_json(tmp_path / "repo")
124 extra = set(data) - self.REQUIRED_KEYS
125 assert not extra, f"Unexpected extra keys: {extra}"
126
127 def test_field_types_correct(self, tmp_path: pathlib.Path) -> None:
128 data = _init_json(tmp_path / "repo")
129 assert isinstance(data["status"], str)
130 assert isinstance(data["error"], str)
131 assert isinstance(data["warnings"], list)
132 assert isinstance(data["repo_id"], str)
133 assert isinstance(data["branch"], str)
134 assert isinstance(data["domain"], str)
135 assert isinstance(data["path"], str)
136 assert isinstance(data["reinitialised"], bool)
137 assert isinstance(data["bare"], bool)
138 assert isinstance(data["schema_version"], int)
139 assert isinstance(data["created_at"], str)
140 assert isinstance(data["duration_ms"], float)
141 assert isinstance(data["exit_code"], int)
142
143
144 # ---------------------------------------------------------------------------
145 # TestStatusField
146 # ---------------------------------------------------------------------------
147
148
149 class TestStatusField:
150 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
151 data = _init_json(tmp_path / "repo")
152 assert data["status"] == "ok"
153
154 def test_status_ok_on_bare(self, tmp_path: pathlib.Path) -> None:
155 data = _init_json(tmp_path / "repo", "--bare")
156 assert data["status"] == "ok"
157
158 def test_status_ok_on_reinit(self, tmp_path: pathlib.Path) -> None:
159 repo = tmp_path / "repo"
160 _init(repo)
161 data = _init_json(repo, "--force")
162 assert data["status"] == "ok"
163
164 def test_error_field_empty_on_success(self, tmp_path: pathlib.Path) -> None:
165 data = _init_json(tmp_path / "repo")
166 assert data["error"] == ""
167
168 def test_status_error_on_bad_branch(self, tmp_path: pathlib.Path) -> None:
169 data, code = _init_json_fail(tmp_path / "repo", "--default-branch", "bad..branch")
170 assert data["status"] == "error"
171 assert code != 0
172
173 def test_status_error_on_bad_domain(self, tmp_path: pathlib.Path) -> None:
174 data, code = _init_json_fail(tmp_path / "repo", "--domain", "BAD_DOMAIN")
175 assert data["status"] == "error"
176
177 def test_status_error_on_existing_no_force(self, tmp_path: pathlib.Path) -> None:
178 repo = tmp_path / "repo"
179 _init(repo)
180 data, _ = _init_json_fail(repo)
181 assert data["status"] == "error"
182
183
184 # ---------------------------------------------------------------------------
185 # TestErrorPayloadShape
186 #
187 # Error payloads must carry a consistent, agent-parseable shape so agents
188 # never have to guess which fields are present after a non-zero exit.
189 # ---------------------------------------------------------------------------
190
191
192 class TestErrorPayloadShape:
193 ERROR_KEYS = {"status", "error", "warnings", "exit_code"}
194
195 def _assert_error_shape(self, data: dict, code: int) -> None:
196 missing = self.ERROR_KEYS - set(data)
197 assert not missing, f"Error payload missing keys: {missing}"
198 assert data["status"] == "error"
199 assert isinstance(data["error"], str) and data["error"]
200 assert isinstance(data["warnings"], list)
201 assert isinstance(data["exit_code"], int)
202 assert data["exit_code"] == code
203
204 def test_bad_branch_error_shape(self, tmp_path: pathlib.Path) -> None:
205 data, code = _init_json_fail(
206 tmp_path / "repo", "--default-branch", "bad..branch"
207 )
208 self._assert_error_shape(data, code)
209
210 def test_bad_domain_error_shape(self, tmp_path: pathlib.Path) -> None:
211 data, code = _init_json_fail(tmp_path / "repo", "--domain", "BAD!")
212 self._assert_error_shape(data, code)
213
214 def test_already_exists_error_shape(self, tmp_path: pathlib.Path) -> None:
215 repo = tmp_path / "repo"
216 _init(repo)
217 data, code = _init_json_fail(repo)
218 self._assert_error_shape(data, code)
219
220 def test_symlink_template_error_shape(self, tmp_path: pathlib.Path) -> None:
221 tmpl_target = tmp_path / "real_dir"
222 tmpl_target.mkdir()
223 symlink = tmp_path / "link_tmpl"
224 symlink.symlink_to(tmpl_target)
225
226 repo = tmp_path / "repo"
227 data, code = _init_json_fail(repo, "--template", str(symlink))
228 self._assert_error_shape(data, code)
229
230 def test_nonexistent_template_error_shape(self, tmp_path: pathlib.Path) -> None:
231 repo = tmp_path / "repo"
232 data, code = _init_json_fail(repo, "--template", str(tmp_path / "no_such"))
233 self._assert_error_shape(data, code)
234
235
236 # ---------------------------------------------------------------------------
237 # TestExitCodeField
238 # ---------------------------------------------------------------------------
239
240
241 class TestExitCodeField:
242 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
243 data = _init_json(tmp_path / "repo")
244 assert data["exit_code"] == 0
245
246 def test_exit_code_zero_on_bare(self, tmp_path: pathlib.Path) -> None:
247 data = _init_json(tmp_path / "repo", "--bare")
248 assert data["exit_code"] == 0
249
250 def test_exit_code_zero_on_reinit(self, tmp_path: pathlib.Path) -> None:
251 repo = tmp_path / "repo"
252 _init(repo)
253 data = _init_json(repo, "--force")
254 assert data["exit_code"] == 0
255
256 def test_exit_code_nonzero_on_bad_branch(self, tmp_path: pathlib.Path) -> None:
257 data, code = _init_json_fail(
258 tmp_path / "repo", "--default-branch", "bad..branch"
259 )
260 assert data["exit_code"] == code
261 assert data["exit_code"] != 0
262
263 def test_exit_code_matches_process_exit_code(
264 self, tmp_path: pathlib.Path
265 ) -> None:
266 repo = tmp_path / "repo"
267 _init(repo)
268 result = _init(repo, "--json") # no --force → should fail
269 data = json.loads(result.output)
270 assert data["exit_code"] == result.exit_code
271
272
273 # ---------------------------------------------------------------------------
274 # TestWarningsField
275 # ---------------------------------------------------------------------------
276
277
278 class TestWarningsField:
279 def test_warnings_empty_on_clean_init(self, tmp_path: pathlib.Path) -> None:
280 data = _init_json(tmp_path / "repo")
281 assert data["warnings"] == []
282
283 def test_warnings_empty_on_bare_init(self, tmp_path: pathlib.Path) -> None:
284 data = _init_json(tmp_path / "repo", "--bare")
285 assert data["warnings"] == []
286
287 def test_warnings_empty_on_reinit_no_template(
288 self, tmp_path: pathlib.Path
289 ) -> None:
290 repo = tmp_path / "repo"
291 _init(repo)
292 data = _init_json(repo, "--force")
293 assert data["warnings"] == []
294
295 def test_warnings_populated_when_template_has_symlinks(
296 self, tmp_path: pathlib.Path
297 ) -> None:
298 tmpl = tmp_path / "tmpl"
299 tmpl.mkdir()
300 (tmpl / "legit.txt").write_text("hello")
301 (tmpl / "evil_link").symlink_to("/etc/passwd")
302
303 repo = tmp_path / "repo"
304 data = _init_json(repo, "--template", str(tmpl))
305 assert len(data["warnings"]) >= 1
306 joined = " ".join(data["warnings"])
307 assert "evil_link" in joined or "symlink" in joined.lower()
308
309 def test_warnings_populated_when_template_has_muse_dir(
310 self, tmp_path: pathlib.Path
311 ) -> None:
312 tmpl = tmp_path / "tmpl"
313 tmpl.mkdir()
314 (tmpl / ".muse").mkdir()
315 (tmpl / ".muse" / "repo.json").write_text('{"repo_id": "evil"}')
316
317 repo = tmp_path / "repo"
318 data = _init_json(repo, "--template", str(tmpl))
319 assert len(data["warnings"]) >= 1
320 joined = " ".join(data["warnings"])
321 assert ".muse" in joined
322
323 def test_warnings_list_always_list_not_null(
324 self, tmp_path: pathlib.Path
325 ) -> None:
326 """warnings must be a list in every code path, never None or absent."""
327 repo = tmp_path / "repo"
328 data = _init_json(repo)
329 assert isinstance(data["warnings"], list)
330
331 def test_multiple_symlinks_produce_multiple_warnings(
332 self, tmp_path: pathlib.Path
333 ) -> None:
334 tmpl = tmp_path / "tmpl"
335 tmpl.mkdir()
336 for i in range(3):
337 (tmpl / f"link_{i}").symlink_to("/etc/passwd")
338
339 repo = tmp_path / "repo"
340 data = _init_json(repo, "--template", str(tmpl))
341 assert len(data["warnings"]) >= 3
342
343
344 # ---------------------------------------------------------------------------
345 # TestDurationMs
346 # ---------------------------------------------------------------------------
347
348
349 class TestDurationMs:
350 def test_duration_ms_present(self, tmp_path: pathlib.Path) -> None:
351 data = _init_json(tmp_path / "repo")
352 assert "duration_ms" in data
353
354 def test_duration_ms_is_non_negative(self, tmp_path: pathlib.Path) -> None:
355 data = _init_json(tmp_path / "repo")
356 assert data["duration_ms"] >= 0.0
357
358 def test_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
359 data = _init_json(tmp_path / "repo")
360 assert isinstance(data["duration_ms"], float)
361
362 def test_no_legacy_timing_keys(self, tmp_path: pathlib.Path) -> None:
363 data = _init_json(tmp_path / "repo")
364 assert "elapsed_ms" not in data
365 assert "elapsed" not in data
366 assert "elapsed_seconds" not in data
367
368
369 # ---------------------------------------------------------------------------
370 # TestCreatedAt
371 # ---------------------------------------------------------------------------
372
373
374 class TestCreatedAt:
375 def test_created_at_present(self, tmp_path: pathlib.Path) -> None:
376 data = _init_json(tmp_path / "repo")
377 assert "created_at" in data
378
379 def test_created_at_is_iso8601_utc(self, tmp_path: pathlib.Path) -> None:
380 data = _init_json(tmp_path / "repo")
381 ts = data["created_at"]
382 # Must parse as a datetime with timezone info
383 dt = datetime.datetime.fromisoformat(ts)
384 assert dt.tzinfo is not None, "created_at must be timezone-aware"
385
386 def test_created_at_is_recent(self, tmp_path: pathlib.Path) -> None:
387 before = datetime.datetime.now(datetime.timezone.utc)
388 data = _init_json(tmp_path / "repo")
389 after = datetime.datetime.now(datetime.timezone.utc)
390 ts = datetime.datetime.fromisoformat(data["created_at"])
391 assert before <= ts <= after
392
393 def test_created_at_changes_on_reinit(self, tmp_path: pathlib.Path) -> None:
394 repo = tmp_path / "repo"
395 d1 = _init_json(repo)
396 import time; time.sleep(0.01)
397 d2 = _init_json(repo, "--force")
398 # New reinit → new timestamp (re-runs init, new created_at)
399 assert d2["created_at"] >= d1["created_at"]
400
401 def test_created_at_matches_repo_json(self, tmp_path: pathlib.Path) -> None:
402 repo = tmp_path / "repo"
403 data = _init_json(repo)
404 stored = json.loads((repo / ".muse" / "repo.json").read_text())
405 assert stored["created_at"] == data["created_at"]
406
407
408 # ---------------------------------------------------------------------------
409 # TestTypeDict — no _InitJson / _InitErrorJson TypedDict currently exists;
410 # these tests verify the module exposes them after the supercharge.
411 # ---------------------------------------------------------------------------
412
413
414 class TestInitJsonTypedDict:
415 def test_init_json_typed_dict_exists(self) -> None:
416 import muse.cli.commands.init as m
417 assert hasattr(m, "_InitJson"), (
418 "_InitJson TypedDict must be defined in init.py"
419 )
420
421 def test_init_error_json_typed_dict_exists(self) -> None:
422 import muse.cli.commands.init as m
423 assert hasattr(m, "_InitErrorJson"), (
424 "_InitErrorJson TypedDict must be defined in init.py"
425 )
426
427 def test_init_json_has_status_key(self) -> None:
428 import muse.cli.commands.init as m
429 hints = m._InitJson.__annotations__
430 assert "status" in hints
431
432 def test_init_json_has_exit_code_key(self) -> None:
433 import muse.cli.commands.init as m
434 hints = m._InitJson.__annotations__
435 assert "exit_code" in hints
436
437 def test_init_json_has_warnings_key(self) -> None:
438 import muse.cli.commands.init as m
439 hints = m._InitJson.__annotations__
440 assert "warnings" in hints
441
442 def test_init_json_has_duration_ms_key(self) -> None:
443 import muse.cli.commands.init as m
444 hints = m._InitJson.__annotations__
445 assert "duration_ms" in hints
446
447
448 # ---------------------------------------------------------------------------
449 # TestDocstringSchema
450 #
451 # The module-level docstring is the canonical API contract for agents.
452 # It must document all fields that the supercharge adds.
453 # ---------------------------------------------------------------------------
454
455
456 class TestDocstringSchema:
457 def test_docstring_documents_status_field(self) -> None:
458 import muse.cli.commands.init as m
459 assert "status" in (m.__doc__ or ""), (
460 "Module docstring must document the 'status' field"
461 )
462
463 def test_docstring_documents_exit_code_field(self) -> None:
464 import muse.cli.commands.init as m
465 assert "exit_code" in (m.__doc__ or ""), (
466 "Module docstring must document the 'exit_code' field"
467 )
468
469 def test_docstring_documents_warnings_field(self) -> None:
470 import muse.cli.commands.init as m
471 assert "warnings" in (m.__doc__ or ""), (
472 "Module docstring must document the 'warnings' field"
473 )
474
475 def test_docstring_documents_duration_ms_field(self) -> None:
476 import muse.cli.commands.init as m
477 assert "duration_ms" in (m.__doc__ or ""), (
478 "Module docstring must document the 'duration_ms' field"
479 )
480
481 def test_docstring_documents_created_at_field(self) -> None:
482 import muse.cli.commands.init as m
483 assert "created_at" in (m.__doc__ or ""), (
484 "Module docstring must document the 'created_at' field"
485 )
486
487 def test_docstring_documents_error_schema(self) -> None:
488 import muse.cli.commands.init as m
489 assert "error" in (m.__doc__ or ""), (
490 "Module docstring must document the error payload shape"
491 )
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 142 days ago