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