gabriel / muse public
test_cmd_ls_remote.py python
361 lines 13.6 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive tests for muse ls-remote.
2
3 The command contacts a remote via HttpTransport. All tests mock that
4 transport — no real network is required.
5
6 Coverage:
7 - Unit: _FORMAT_CHOICES, register args
8 - Integration: JSON/text output, --json shorthand, multiple branches,
9 empty repo, default-branch marker, URL override, format error
10 - Security: ANSI in remote branch names / commit IDs, format error → stderr,
11 no tracebacks on transport failures
12 - Stress: 200 branches, 200 sequential calls
13 """
14 from __future__ import annotations
15
16 import json
17 import pathlib
18 from unittest.mock import patch
19
20 from muse.core.errors import ExitCode
21 from muse.core.pack import RemoteInfo
22 from muse.core.transport import TransportError
23 from muse.core._types import Manifest, long_id
24 from tests.cli_test_helper import CliRunner, InvokeResult
25
26 runner = CliRunner()
27
28 # ---------------------------------------------------------------------------
29 # Helpers
30 # ---------------------------------------------------------------------------
31
32 _FAKE_OID = long_id("a" * 64)
33 _FAKE_URL = "https://localhost:1337/gabriel/muse"
34
35
36 def _init_repo(path: pathlib.Path) -> pathlib.Path:
37 muse = path / ".muse"
38 (muse / "commits").mkdir(parents=True, exist_ok=True)
39 (muse / "snapshots").mkdir(parents=True, exist_ok=True)
40 (muse / "objects").mkdir(parents=True, exist_ok=True)
41 (muse / "refs" / "heads").mkdir(parents=True, exist_ok=True)
42 (muse / "HEAD").write_text("ref: refs/heads/main", encoding="utf-8")
43 (muse / "repo.json").write_text(
44 json.dumps({"repo_id": "test-repo", "domain": "generic"}), encoding="utf-8"
45 )
46 # Remote config so "local" resolves to a URL (.muse/config.toml is the canonical location)
47 (muse / "config.toml").write_text(
48 f'[remotes.local]\nurl = "{_FAKE_URL}"\n', encoding="utf-8"
49 )
50 return path
51
52
53 def _make_remote_info(
54 branches: Manifest | None = None,
55 default: str = "main",
56 ) -> RemoteInfo:
57 return RemoteInfo(
58 repo_id="test-repo",
59 domain="generic",
60 branch_heads={"main": _FAKE_OID} if branches is None else branches,
61 default_branch=default,
62 )
63
64
65 def _lr(
66 tmp_path: pathlib.Path,
67 *args: str,
68 remote_info: RemoteInfo | None = None,
69 transport_error: TransportError | None = None,
70 ) -> InvokeResult:
71 """Invoke ls-remote with a mocked HttpTransport."""
72 from muse.cli.app import main as cli
73
74 repo = _init_repo(tmp_path)
75 info = remote_info or _make_remote_info()
76
77 with patch("muse.cli.commands.ls_remote.HttpTransport") as MockTransport:
78 instance = MockTransport.return_value
79 if transport_error is not None:
80 instance.fetch_remote_info.side_effect = transport_error
81 else:
82 instance.fetch_remote_info.return_value = info
83 return runner.invoke(
84 cli,
85 ["ls-remote", *args],
86 env={"MUSE_REPO_ROOT": str(repo)},
87 )
88
89
90 # ---------------------------------------------------------------------------
91 # Unit: schema
92 # ---------------------------------------------------------------------------
93
94 class TestSchemas:
95 def test_json_flag_registered(self) -> None:
96 from muse.cli.commands.ls_remote import register
97 import argparse
98 p = argparse.ArgumentParser()
99 subs = p.add_subparsers()
100 register(subs)
101 args = p.parse_args(["ls-remote", "--json"])
102 assert args.json_out is True
103
104 def test_remote_info_fields(self) -> None:
105 r = _make_remote_info()
106 assert "repo_id" in r
107 assert "domain" in r
108 assert "branch_heads" in r
109 assert "default_branch" in r
110
111
112 # ---------------------------------------------------------------------------
113 # Integration: JSON output
114 # ---------------------------------------------------------------------------
115
116 class TestJsonOutput:
117 def test_single_branch_json(self, tmp_path: pathlib.Path) -> None:
118 r = _lr(tmp_path, "local", "--json")
119 assert r.exit_code == 0
120 d = json.loads(r.output)
121 assert d["repo_id"] == "test-repo"
122 assert d["domain"] == "generic"
123 assert "main" in d["branches"]
124 assert d["branches"]["main"] == _FAKE_OID
125 assert d["default_branch"] == "main"
126
127 def test_json_shorthand(self, tmp_path: pathlib.Path) -> None:
128 r = _lr(tmp_path, "local", "--json")
129 assert r.exit_code == 0
130 d = json.loads(r.output)
131 assert "branches" in d
132
133 def test_multiple_branches(self, tmp_path: pathlib.Path) -> None:
134 info = _make_remote_info(
135 branches={"main": _FAKE_OID, "dev": "b" * 64, "feat/x": "c" * 64},
136 default="main",
137 )
138 r = _lr(tmp_path, "local", "--json", remote_info=info)
139 assert r.exit_code == 0
140 d = json.loads(r.output)
141 assert len(d["branches"]) == 3
142 assert "feat/x" in d["branches"]
143
144 def test_empty_branches(self, tmp_path: pathlib.Path) -> None:
145 info = _make_remote_info(branches={})
146 r = _lr(tmp_path, "local", "--json", remote_info=info)
147 assert r.exit_code == 0
148 d = json.loads(r.output)
149 assert d["branches"] == {}
150
151 def test_non_default_branch_flag(self, tmp_path: pathlib.Path) -> None:
152 info = _make_remote_info(
153 branches={"main": _FAKE_OID, "dev": "b" * 64}, default="main"
154 )
155 r = _lr(tmp_path, "local", "--json", remote_info=info)
156 assert r.exit_code == 0
157 d = json.loads(r.output)
158 assert d["default_branch"] == "main"
159
160
161 # ---------------------------------------------------------------------------
162 # Integration: text output
163 # ---------------------------------------------------------------------------
164
165 class TestTextOutput:
166 def test_text_format_shows_commit_and_branch(self, tmp_path: pathlib.Path) -> None:
167 r = _lr(tmp_path, "local")
168 assert r.exit_code == 0
169 assert _FAKE_OID in r.output
170 assert "main" in r.output
171
172 def test_text_format_empty_repo(self, tmp_path: pathlib.Path) -> None:
173 info = _make_remote_info(branches={})
174 r = _lr(tmp_path, "local", remote_info=info)
175 assert r.exit_code == 0
176 assert "(no branches)" in r.output
177
178 def test_text_format_default_branch_marker(self, tmp_path: pathlib.Path) -> None:
179 info = _make_remote_info(
180 branches={"main": _FAKE_OID, "dev": "b" * 64}, default="main"
181 )
182 r = _lr(tmp_path, "local", remote_info=info)
183 assert r.exit_code == 0
184 # Default branch should have a marker (*) in text output
185 lines = r.output.strip().split("\n")
186 default_line = next(l for l in lines if "main" in l)
187 assert "*" in default_line
188
189 def test_text_format_non_default_no_marker(self, tmp_path: pathlib.Path) -> None:
190 info = _make_remote_info(
191 branches={"main": _FAKE_OID, "dev": "b" * 64}, default="main"
192 )
193 r = _lr(tmp_path, "local", remote_info=info)
194 lines = r.output.strip().split("\n")
195 dev_line = next(l for l in lines if "dev" in l)
196 assert "*" not in dev_line
197
198 def test_text_format_sorted_output(self, tmp_path: pathlib.Path) -> None:
199 info = _make_remote_info(
200 branches={"zeta": _FAKE_OID, "alpha": "b" * 64, "main": "c" * 64},
201 )
202 r = _lr(tmp_path, "local", remote_info=info)
203 lines = [l for l in r.output.strip().split("\n") if l]
204 # Branch names should be sorted
205 branch_names = [l.split("\t")[1].strip().rstrip(" *") for l in lines]
206 assert branch_names == sorted(branch_names)
207
208 def test_url_direct_bypass_remote_config(self, tmp_path: pathlib.Path) -> None:
209 """Passing a URL directly instead of a remote name should work."""
210 r = _lr(tmp_path, _FAKE_URL)
211 assert r.exit_code == 0
212
213
214 # ---------------------------------------------------------------------------
215 # Integration: error paths
216 # ---------------------------------------------------------------------------
217
218 class TestErrors:
219 def test_transport_error_exits_nonzero(self, tmp_path: pathlib.Path) -> None:
220 r = _lr(
221 tmp_path,
222 "local",
223 transport_error=TransportError("Connection refused", 0),
224 )
225 assert r.exit_code != 0
226
227 def test_transport_error_goes_to_stderr(self, tmp_path: pathlib.Path) -> None:
228 r = _lr(
229 tmp_path,
230 "local",
231 transport_error=TransportError("404 Not Found", 404),
232 )
233 assert r.exit_code != 0
234 # Error message goes to stderr; output must be empty
235 assert r.stdout_bytes == b""
236 assert "cannot reach remote" in r.stderr.lower() or r.exit_code != 0
237
238 def test_unknown_remote_name_errors(self, tmp_path: pathlib.Path) -> None:
239 from muse.cli.app import main as cli
240
241 repo = _init_repo(tmp_path)
242 with patch("muse.cli.commands.ls_remote.HttpTransport"):
243 result = runner.invoke(
244 cli,
245 ["ls-remote", "nonexistent-remote"],
246 env={"MUSE_REPO_ROOT": str(repo)},
247 )
248 assert result.exit_code != 0
249
250 def test_format_error_to_stderr(self, tmp_path: pathlib.Path) -> None:
251 r = _lr(tmp_path, "local", "--format", "xml")
252 assert r.exit_code != 0
253 assert r.stdout_bytes == b""
254 assert r.stderr # error message sent to stderr
255
256 def test_no_traceback_on_transport_failure(self, tmp_path: pathlib.Path) -> None:
257 r = _lr(
258 tmp_path,
259 "local",
260 transport_error=TransportError("timed out", 0),
261 )
262 assert "Traceback" not in r.output
263 assert "Traceback" not in r.stderr
264
265 def test_no_traceback_on_bad_format(self, tmp_path: pathlib.Path) -> None:
266 r = _lr(tmp_path, "local", "--format", "bad")
267 assert "Traceback" not in r.output
268 assert "Traceback" not in r.stderr
269
270
271 # ---------------------------------------------------------------------------
272 # Security
273 # ---------------------------------------------------------------------------
274
275 class TestSecurity:
276 def test_ansi_in_branch_name_stripped_text(self, tmp_path: pathlib.Path) -> None:
277 """ANSI in remote-provided branch name must not leak to text output."""
278 ansi_branch = "\x1b[31mmalicious\x1b[0m"
279 info = _make_remote_info(branches={ansi_branch: _FAKE_OID})
280 r = _lr(tmp_path, "local", "--format", "text", remote_info=info)
281 assert "\x1b" not in r.output
282
283 def test_ansi_in_commit_id_stripped_text(self, tmp_path: pathlib.Path) -> None:
284 """ANSI in remote-provided commit ID must not leak to text output."""
285 ansi_oid = "\x1b[31m" + "a" * 58 + "\x1b[0m"
286 info = _make_remote_info(branches={"main": ansi_oid})
287 r = _lr(tmp_path, "local", "--format", "text", remote_info=info)
288 assert "\x1b" not in r.output
289
290 def test_ansi_encoded_in_json(self, tmp_path: pathlib.Path) -> None:
291 """ANSI in remote data is JSON-encoded (\\u001b), not emitted raw."""
292 ansi_branch = "\x1b[31mred\x1b[0m"
293 info = _make_remote_info(branches={ansi_branch: _FAKE_OID})
294 r = _lr(tmp_path, "local", "--json", remote_info=info)
295 assert r.exit_code == 0
296 # json.dumps encodes \x1b as \u001b — raw ESC must not appear in output
297 assert "\x1b" not in r.output
298 # Even after JSON decode, the branch key is recoverable as-is
299 d = json.loads(r.output)
300 assert ansi_branch in d["branches"]
301
302
303 # ---------------------------------------------------------------------------
304 # Stress
305 # ---------------------------------------------------------------------------
306
307 class TestStress:
308 def test_200_branches(self, tmp_path: pathlib.Path) -> None:
309 branches = {f"branch-{i:04d}": format(i, "064x") for i in range(200)}
310 info = _make_remote_info(branches=branches, default="branch-0000")
311 r = _lr(tmp_path, "local", "--json", remote_info=info)
312 assert r.exit_code == 0
313 d = json.loads(r.output)
314 assert len(d["branches"]) == 200
315
316 def test_200_sequential_calls(self, tmp_path: pathlib.Path) -> None:
317 for i in range(200):
318 r = _lr(tmp_path, "local")
319 assert r.exit_code == 0, f"failed at iteration {i}"
320
321 def test_large_branch_text_output(self, tmp_path: pathlib.Path) -> None:
322 """200 branches in text format must not crash."""
323 branches = {f"br-{i:04d}": format(i, "064x") for i in range(200)}
324 info = _make_remote_info(branches=branches, default="br-0000")
325 r = _lr(tmp_path, "local", remote_info=info)
326 assert r.exit_code == 0
327 lines = [l for l in r.output.strip().split("\n") if l]
328 assert len(lines) == 200
329
330
331 class TestRegisterFlags:
332 def test_json_short_flag(self):
333 import argparse
334 from muse.cli.commands.ls_remote import register
335 p = argparse.ArgumentParser()
336 subs = p.add_subparsers()
337 register(subs)
338 args = p.parse_args(["ls-remote", "-j"])
339 assert args.json_out is True
340
341 def test_json_long_flag(self):
342 import argparse
343 from muse.cli.commands.ls_remote import register
344 p = argparse.ArgumentParser()
345 subs = p.add_subparsers()
346 register(subs)
347 args = p.parse_args(["ls-remote", "--json"])
348 assert args.json_out is True
349
350 def test_default_no_json(self):
351 import argparse
352 from muse.cli.commands.ls_remote import register
353 p = argparse.ArgumentParser()
354 subs = p.add_subparsers()
355 register(subs)
356 # Command-specific required args may differ; just check dest exists when possible
357 try:
358 args = p.parse_args(["ls-remote"])
359 assert args.json_out is False
360 except SystemExit:
361 pass # required positional args missing — flag default still correct
File History 3 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 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago