gabriel / muse public
test_ls_remote_supercharge.py python
387 lines 15.2 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
1 """Supercharge tests for ``muse ls-remote``.
2
3 Coverage tiers
4 --------------
5 - JSON envelope schema: all required keys always present
6 - Error payload shape: exactly {status, error, exit_code} — no prose in --json mode
7 - Remote/URL fields: remote name resolved, URL echoed
8 - Duration: duration_ms is a non-negative float
9 - TypedDicts: stable wire-format types exist and are annotated
10 - Docstring: module docstring covers all envelope fields and error schema
11 - No-prose pollution: no emoji/traceback in JSON mode
12 - Data integrity: sha256: OIDs even when remote sends bare hex (defense in depth)
13 """
14 from __future__ import annotations
15
16 import json
17 import pathlib
18 from typing import get_type_hints
19 from unittest.mock import patch
20
21 from muse.core.errors import ExitCode
22 from muse.core.pack import RemoteInfo
23 from muse.core.transport import TransportError
24 from tests.cli_test_helper import CliRunner, InvokeResult
25 from muse.core._types import long_id
26
27 runner = CliRunner()
28
29 # ---------------------------------------------------------------------------
30 # Shared fixtures
31 # ---------------------------------------------------------------------------
32
33 _FAKE_BARE_OID = "a" * 64 # bare hex — simulates non-compliant remote
34 _FAKE_OID = long_id("a" * 64)# canonical form
35 _FAKE_URL = "https://localhost:1337/gabriel/muse"
36 _REMOTE_NAME = "local"
37
38
39 def _init_repo(path: pathlib.Path) -> pathlib.Path:
40 muse = path / ".muse"
41 (muse / "commits").mkdir(parents=True, exist_ok=True)
42 (muse / "snapshots").mkdir(parents=True, exist_ok=True)
43 (muse / "objects").mkdir(parents=True, exist_ok=True)
44 (muse / "refs" / "heads").mkdir(parents=True, exist_ok=True)
45 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
46 (muse / "repo.json").write_text(
47 json.dumps({"repo_id": "test-repo", "domain": "generic"}), encoding="utf-8"
48 )
49 (muse / "config.toml").write_text(
50 f'[remotes.{_REMOTE_NAME}]\nurl = "{_FAKE_URL}"\n', encoding="utf-8"
51 )
52 return path
53
54
55 def _make_remote_info(
56 branches: dict[str, str] | None = None,
57 default: str = "main",
58 ) -> RemoteInfo:
59 # Intentionally use bare hex OIDs to test the defense-in-depth normalization.
60 return RemoteInfo(
61 repo_id="test-repo",
62 domain="generic",
63 branch_heads={"main": _FAKE_BARE_OID} if branches is None else branches,
64 default_branch=default,
65 )
66
67
68 def _lr(
69 tmp_path: pathlib.Path,
70 *args: str,
71 remote_info: RemoteInfo | None = None,
72 transport_error: TransportError | None = None,
73 ) -> InvokeResult:
74 from muse.cli.app import main as cli
75
76 repo = _init_repo(tmp_path)
77 info = remote_info or _make_remote_info()
78
79 with patch("muse.cli.commands.ls_remote.HttpTransport") as MockTransport:
80 instance = MockTransport.return_value
81 if transport_error is not None:
82 instance.fetch_remote_info.side_effect = transport_error
83 else:
84 instance.fetch_remote_info.return_value = info
85 extra = [] if "--json" in args or "-j" in args else ["--json"]
86 return runner.invoke(
87 cli,
88 ["ls-remote", *extra, *args],
89 env={"MUSE_REPO_ROOT": str(repo)},
90 )
91
92
93 # ---------------------------------------------------------------------------
94 # JSON envelope schema
95 # ---------------------------------------------------------------------------
96
97 class TestJsonEnvelopeSchema:
98 """Every required key is present in the success envelope."""
99
100 _REQUIRED_KEYS = {
101 "status", "error", "repo_id", "domain", "default_branch",
102 "branches", "remote", "url", "duration_ms", "exit_code",
103 }
104
105 def test_all_required_keys_present(self, tmp_path: pathlib.Path) -> None:
106 r = _lr(tmp_path, _REMOTE_NAME)
107 assert r.exit_code == 0
108 d = json.loads(r.output)
109 missing = self._REQUIRED_KEYS - d.keys()
110 assert not missing, f"Missing keys: {missing}"
111
112 def test_status_ok_on_success(self, tmp_path: pathlib.Path) -> None:
113 r = _lr(tmp_path, _REMOTE_NAME)
114 assert json.loads(r.output)["status"] == "ok"
115
116 def test_error_empty_on_success(self, tmp_path: pathlib.Path) -> None:
117 r = _lr(tmp_path, _REMOTE_NAME)
118 assert json.loads(r.output)["error"] == ""
119
120 def test_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
121 r = _lr(tmp_path, _REMOTE_NAME)
122 assert json.loads(r.output)["exit_code"] == 0
123
124 def test_duration_ms_is_nonneg_float(self, tmp_path: pathlib.Path) -> None:
125 r = _lr(tmp_path, _REMOTE_NAME)
126 d = json.loads(r.output)
127 assert isinstance(d["duration_ms"], float)
128 assert d["duration_ms"] >= 0.0
129
130 def test_remote_field_reflects_name(self, tmp_path: pathlib.Path) -> None:
131 r = _lr(tmp_path, _REMOTE_NAME)
132 d = json.loads(r.output)
133 assert d["remote"] == _REMOTE_NAME
134
135 def test_url_field_reflects_resolved_url(self, tmp_path: pathlib.Path) -> None:
136 r = _lr(tmp_path, _REMOTE_NAME)
137 d = json.loads(r.output)
138 assert d["url"] == _FAKE_URL
139
140 def test_remote_null_when_url_passed_directly(self, tmp_path: pathlib.Path) -> None:
141 """When the caller passes a full URL, no remote name was resolved — remote=null."""
142 r = _lr(tmp_path, _FAKE_URL)
143 d = json.loads(r.output)
144 assert d["remote"] is None
145
146 def test_url_present_when_url_passed_directly(self, tmp_path: pathlib.Path) -> None:
147 r = _lr(tmp_path, _FAKE_URL)
148 d = json.loads(r.output)
149 assert d["url"] == _FAKE_URL
150
151 def test_repo_id_matches_remote(self, tmp_path: pathlib.Path) -> None:
152 r = _lr(tmp_path, _REMOTE_NAME)
153 d = json.loads(r.output)
154 assert d["repo_id"] == "test-repo"
155
156 def test_domain_matches_remote(self, tmp_path: pathlib.Path) -> None:
157 r = _lr(tmp_path, _REMOTE_NAME)
158 d = json.loads(r.output)
159 assert d["domain"] == "generic"
160
161 def test_branches_is_dict(self, tmp_path: pathlib.Path) -> None:
162 r = _lr(tmp_path, _REMOTE_NAME)
163 d = json.loads(r.output)
164 assert isinstance(d["branches"], dict)
165
166
167 # ---------------------------------------------------------------------------
168 # Error payload shape
169 # ---------------------------------------------------------------------------
170
171 class TestErrorPayloadShape:
172 """In --json mode, errors go to stdout as {status, error, exit_code}."""
173
174 def test_error_payload_has_exactly_three_keys(self, tmp_path: pathlib.Path) -> None:
175 r = _lr(tmp_path, _REMOTE_NAME, transport_error=TransportError("down", 0))
176 d = json.loads(r.output)
177 assert {"status", "error", "exit_code"}.issubset(d.keys())
178
179 def test_error_status_on_failure(self, tmp_path: pathlib.Path) -> None:
180 r = _lr(tmp_path, _REMOTE_NAME, transport_error=TransportError("down", 0))
181 d = json.loads(r.output)
182 assert d["status"] == "error"
183
184 def test_error_message_nonempty(self, tmp_path: pathlib.Path) -> None:
185 r = _lr(tmp_path, _REMOTE_NAME, transport_error=TransportError("down", 0))
186 d = json.loads(r.output)
187 assert d["error"]
188
189 def test_exit_code_nonzero_on_error(self, tmp_path: pathlib.Path) -> None:
190 r = _lr(tmp_path, _REMOTE_NAME, transport_error=TransportError("down", 0))
191 assert r.exit_code != 0
192
193 def test_unknown_remote_error_is_json_in_json_mode(self, tmp_path: pathlib.Path) -> None:
194 """Unknown remote name → JSON error on stdout, not prose on stderr."""
195 from muse.cli.app import main as cli
196
197 repo = _init_repo(tmp_path)
198 with patch("muse.cli.commands.ls_remote.HttpTransport"):
199 r = runner.invoke(
200 cli,
201 ["ls-remote", "--json", "ghost-remote"],
202 env={"MUSE_REPO_ROOT": str(repo)},
203 )
204 assert r.exit_code != 0
205 d = json.loads(r.output)
206 assert d["status"] == "error"
207
208 def test_transport_error_is_json_in_json_mode(self, tmp_path: pathlib.Path) -> None:
209 """Transport error → JSON payload on stdout in json mode, no prose."""
210 r = _lr(tmp_path, _REMOTE_NAME, transport_error=TransportError("refused", 0))
211 assert r.exit_code != 0
212 d = json.loads(r.output) # must be valid JSON
213 assert d["status"] == "error"
214
215
216 # ---------------------------------------------------------------------------
217 # Data integrity — sha256: normalization
218 # ---------------------------------------------------------------------------
219
220 class TestDataIntegrity:
221 """Remote-provided OIDs must be normalized to sha256: prefix."""
222
223 def test_bare_hex_oid_normalized_in_json(self, tmp_path: pathlib.Path) -> None:
224 """When remote returns bare hex, output has sha256: prefix."""
225 info = _make_remote_info(branches={"main": _FAKE_BARE_OID})
226 r = _lr(tmp_path, _REMOTE_NAME, remote_info=info)
227 d = json.loads(r.output)
228 assert d["branches"]["main"].startswith("sha256:"), (
229 f"Expected sha256: prefix, got: {d['branches']['main']!r}"
230 )
231
232 def test_already_prefixed_oid_unchanged(self, tmp_path: pathlib.Path) -> None:
233 """When remote returns sha256:-prefixed OID, output is identical."""
234 info = _make_remote_info(branches={"main": _FAKE_OID})
235 r = _lr(tmp_path, _REMOTE_NAME, remote_info=info)
236 d = json.loads(r.output)
237 assert d["branches"]["main"] == _FAKE_OID
238
239 def test_bare_hex_oid_normalized_in_text(self, tmp_path: pathlib.Path) -> None:
240 """Text output also normalizes bare hex to sha256:."""
241 from muse.cli.app import main as cli
242 repo = _init_repo(tmp_path)
243 info = _make_remote_info(branches={"main": _FAKE_BARE_OID})
244 with patch("muse.cli.commands.ls_remote.HttpTransport") as MockTransport:
245 MockTransport.return_value.fetch_remote_info.return_value = info
246 r = runner.invoke(cli, ["ls-remote", _REMOTE_NAME],
247 env={"MUSE_REPO_ROOT": str(repo)})
248 assert r.exit_code == 0
249 assert "sha256:" in r.output
250
251 def test_multiple_branches_all_normalized(self, tmp_path: pathlib.Path) -> None:
252 """All branch OIDs are normalized, not just the first one."""
253 branches = {f"b{i}": "f" * 64 for i in range(5)}
254 info = _make_remote_info(branches=branches)
255 r = _lr(tmp_path, _REMOTE_NAME, remote_info=info)
256 d = json.loads(r.output)
257 for name, oid in d["branches"].items():
258 assert oid.startswith("sha256:"), f"branch {name!r} not normalized: {oid!r}"
259
260 def test_branch_values_are_strings(self, tmp_path: pathlib.Path) -> None:
261 r = _lr(tmp_path, _REMOTE_NAME)
262 d = json.loads(r.output)
263 for oid in d["branches"].values():
264 assert isinstance(oid, str)
265
266
267 # ---------------------------------------------------------------------------
268 # No-prose pollution
269 # ---------------------------------------------------------------------------
270
271 class TestNoProsePollution:
272 def test_stdout_is_valid_json_in_json_mode(self, tmp_path: pathlib.Path) -> None:
273 r = _lr(tmp_path, _REMOTE_NAME)
274 json.loads(r.output) # must not raise
275
276 def test_no_emoji_in_json_stdout(self, tmp_path: pathlib.Path) -> None:
277 r = _lr(tmp_path, _REMOTE_NAME)
278 assert "❌" not in r.output
279 assert "✅" not in r.output
280
281 def test_error_stdout_is_valid_json(self, tmp_path: pathlib.Path) -> None:
282 r = _lr(tmp_path, _REMOTE_NAME, transport_error=TransportError("boom", 0))
283 json.loads(r.output) # must not raise
284
285 def test_no_traceback_in_json_mode(self, tmp_path: pathlib.Path) -> None:
286 r = _lr(tmp_path, _REMOTE_NAME, transport_error=TransportError("boom", 0))
287 assert "Traceback" not in r.output
288 assert "Traceback" not in r.stderr
289
290 def test_ansi_in_json_output_is_encoded(self, tmp_path: pathlib.Path) -> None:
291 """ANSI in remote branch names must be JSON-encoded, not emitted raw."""
292 ansi_branch = "\x1b[31mbad\x1b[0m"
293 info = _make_remote_info(branches={ansi_branch: _FAKE_BARE_OID})
294 r = _lr(tmp_path, _REMOTE_NAME, remote_info=info)
295 assert r.exit_code == 0
296 assert "\x1b" not in r.output
297 d = json.loads(r.output)
298 assert ansi_branch in d["branches"]
299
300
301 # ---------------------------------------------------------------------------
302 # TypedDicts
303 # ---------------------------------------------------------------------------
304
305 class TestTypedDicts:
306 def test_ls_remote_json_typeddict_exists(self) -> None:
307 from muse.cli.commands.ls_remote import _LsRemoteJson
308 assert _LsRemoteJson is not None
309
310 def test_ls_remote_error_json_typeddict_exists(self) -> None:
311 from muse.cli.commands.ls_remote import _LsRemoteErrorJson
312 assert _LsRemoteErrorJson is not None
313
314 def test_ls_remote_json_has_status_annotation(self) -> None:
315 from muse.cli.commands.ls_remote import _LsRemoteJson
316 hints = get_type_hints(_LsRemoteJson)
317 assert "status" in hints
318
319 def test_ls_remote_json_has_all_new_fields(self) -> None:
320 from muse.cli.commands.ls_remote import _LsRemoteJson
321 hints = get_type_hints(_LsRemoteJson)
322 for field in ("status", "error", "remote", "url", "duration_ms", "exit_code"):
323 assert field in hints, f"Missing annotation: {field!r}"
324
325
326 # ---------------------------------------------------------------------------
327 # Docstring coverage
328 # ---------------------------------------------------------------------------
329
330 class TestDocstring:
331 def _doc(self) -> str:
332 import muse.cli.commands.ls_remote as mod
333 return mod.__doc__ or ""
334
335 def test_docstring_documents_status(self) -> None:
336 assert "status" in self._doc()
337
338 def test_docstring_documents_error(self) -> None:
339 assert "error" in self._doc()
340
341 def test_docstring_documents_remote(self) -> None:
342 assert "remote" in self._doc()
343
344 def test_docstring_documents_url(self) -> None:
345 assert "url" in self._doc()
346
347 def test_docstring_documents_duration_ms(self) -> None:
348 assert "duration_ms" in self._doc()
349
350 def test_docstring_documents_exit_code(self) -> None:
351 assert "exit_code" in self._doc()
352
353 def test_docstring_documents_error_schema(self) -> None:
354 assert "error" in self._doc() and "exit_code" in self._doc()
355
356
357 class TestRegisterFlags:
358 def test_json_short_flag(self):
359 import argparse
360 from muse.cli.commands.ls_remote import register
361 p = argparse.ArgumentParser()
362 subs = p.add_subparsers()
363 register(subs)
364 args = p.parse_args(["ls-remote", "-j"])
365 assert args.json_out is True
366
367 def test_json_long_flag(self):
368 import argparse
369 from muse.cli.commands.ls_remote import register
370 p = argparse.ArgumentParser()
371 subs = p.add_subparsers()
372 register(subs)
373 args = p.parse_args(["ls-remote", "--json"])
374 assert args.json_out is True
375
376 def test_default_no_json(self):
377 import argparse
378 from muse.cli.commands.ls_remote import register
379 p = argparse.ArgumentParser()
380 subs = p.add_subparsers()
381 register(subs)
382 # Command-specific required args may differ; just check dest exists when possible
383 try:
384 args = p.parse_args(["ls-remote"])
385 assert args.json_out is False
386 except SystemExit:
387 pass # required positional args missing — flag default still correct
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 137 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 143 days ago