gabriel / muse public
test_remote_supercharge.py python
455 lines 21.4 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago
1 """Supercharge tests for muse remote — agent-first JSON envelope.
2
3 All seven subcommands (list, add, remove, rename, get-url, set-url, status)
4 must emit ``duration_ms`` and ``exit_code`` in every JSON response — success
5 and error alike. Agents poll these to measure latency and confirm outcomes
6 without parsing exit codes separately.
7
8 Coverage tiers
9 --------------
10 I Unit — TypedDict field presence
11 II Integration — every subcommand JSON success path carries the envelope
12 III Integration — every subcommand JSON error path carries the envelope
13 IV End-to-end — exit_code in JSON matches the process exit code
14 V Data integrity — duration_ms is a non-negative integer; exit_code is int
15 VI Security — envelope present even on validation-rejected inputs
16 VII Performance — local subcommands complete within 200 ms
17 """
18
19 from __future__ import annotations
20
21 import json
22 import time
23 import pathlib
24 import threading
25
26 import pytest
27
28 from tests.cli_test_helper import CliRunner
29
30 runner = CliRunner()
31
32 # ---------------------------------------------------------------------------
33 # Fixtures
34 # ---------------------------------------------------------------------------
35
36 @pytest.fixture()
37 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
38 """Minimal Muse repo with .muse/config.toml wired up."""
39 muse_dir = tmp_path / ".muse"
40 muse_dir.mkdir()
41 (muse_dir / "config.toml").write_text('[repo]\nname = "test"\n')
42 monkeypatch.chdir(tmp_path)
43 return tmp_path
44
45
46 @pytest.fixture()
47 def repo_with_origin(repo: pathlib.Path) -> pathlib.Path:
48 """Repo pre-loaded with a single remote named 'origin'."""
49 runner.invoke(None, ["remote", "add", "origin", "https://musehub.ai/gabriel/test"])
50 return repo
51
52
53 # ---------------------------------------------------------------------------
54 # I Unit — TypedDict field presence
55 # ---------------------------------------------------------------------------
56
57 class TestTypedDictFields:
58 def test_I1_remote_list_json_has_duration_ms(self) -> None:
59 """_RemoteListJson TypedDict must declare duration_ms."""
60 from muse.cli.commands.remote import _RemoteListJson
61 import typing
62 hints = typing.get_type_hints(_RemoteListJson)
63 assert "duration_ms" in hints, "_RemoteListJson missing duration_ms field"
64
65 def test_I2_remote_list_json_has_exit_code(self) -> None:
66 """_RemoteListJson TypedDict must declare exit_code."""
67 from muse.cli.commands.remote import _RemoteListJson
68 import typing
69 hints = typing.get_type_hints(_RemoteListJson)
70 assert "exit_code" in hints, "_RemoteListJson missing exit_code field"
71
72 def test_I3_mutation_json_has_duration_ms(self) -> None:
73 """_RemoteMutationJson TypedDict must declare duration_ms."""
74 from muse.cli.commands.remote import _RemoteMutationJson
75 import typing
76 hints = typing.get_type_hints(_RemoteMutationJson)
77 assert "duration_ms" in hints, "_RemoteMutationJson missing duration_ms field"
78
79 def test_I4_mutation_json_has_exit_code(self) -> None:
80 """_RemoteMutationJson TypedDict must declare exit_code."""
81 from muse.cli.commands.remote import _RemoteMutationJson
82 import typing
83 hints = typing.get_type_hints(_RemoteMutationJson)
84 assert "exit_code" in hints, "_RemoteMutationJson missing exit_code field"
85
86 def test_I5_get_url_json_has_duration_ms(self) -> None:
87 """_RemoteGetUrlJson TypedDict must declare duration_ms."""
88 from muse.cli.commands.remote import _RemoteGetUrlJson
89 import typing
90 hints = typing.get_type_hints(_RemoteGetUrlJson)
91 assert "duration_ms" in hints, "_RemoteGetUrlJson missing duration_ms field"
92
93 def test_I6_get_url_json_has_exit_code(self) -> None:
94 """_RemoteGetUrlJson TypedDict must declare exit_code."""
95 from muse.cli.commands.remote import _RemoteGetUrlJson
96 import typing
97 hints = typing.get_type_hints(_RemoteGetUrlJson)
98 assert "exit_code" in hints, "_RemoteGetUrlJson missing exit_code field"
99
100 def test_I7_status_json_has_duration_ms(self) -> None:
101 """_RemoteStatusJson TypedDict must declare duration_ms."""
102 from muse.cli.commands.remote import _RemoteStatusJson
103 import typing
104 hints = typing.get_type_hints(_RemoteStatusJson)
105 assert "duration_ms" in hints, "_RemoteStatusJson missing duration_ms field"
106
107 def test_I8_status_json_has_exit_code(self) -> None:
108 """_RemoteStatusJson TypedDict must declare exit_code."""
109 from muse.cli.commands.remote import _RemoteStatusJson
110 import typing
111 hints = typing.get_type_hints(_RemoteStatusJson)
112 assert "exit_code" in hints, "_RemoteStatusJson missing exit_code field"
113
114
115 # ---------------------------------------------------------------------------
116 # II Integration — success JSON carries envelope
117 # ---------------------------------------------------------------------------
118
119 class TestSuccessEnvelope:
120 def test_II1_list_json_has_envelope(self, repo: pathlib.Path) -> None:
121 """muse remote --json must include duration_ms and exit_code."""
122 r = runner.invoke(None, ["remote", "--json"])
123 data = json.loads(r.output)
124 assert "duration_ms" in data, "list JSON missing duration_ms"
125 assert "exit_code" in data, "list JSON missing exit_code"
126
127 def test_II2_add_json_has_envelope(self, repo: pathlib.Path) -> None:
128 """muse remote add --json must include duration_ms and exit_code."""
129 r = runner.invoke(None, ["remote", "add", "origin",
130 "https://musehub.ai/gabriel/test", "--json"])
131 data = json.loads(r.output)
132 assert "duration_ms" in data, "add JSON missing duration_ms"
133 assert "exit_code" in data, "add JSON missing exit_code"
134
135 def test_II3_remove_json_has_envelope(self, repo_with_origin: pathlib.Path) -> None:
136 """muse remote remove --json must include duration_ms and exit_code."""
137 r = runner.invoke(None, ["remote", "remove", "origin", "--json"])
138 data = json.loads(r.output)
139 assert "duration_ms" in data, "remove JSON missing duration_ms"
140 assert "exit_code" in data, "remove JSON missing exit_code"
141
142 def test_II4_rename_json_has_envelope(self, repo_with_origin: pathlib.Path) -> None:
143 """muse remote rename --json must include duration_ms and exit_code."""
144 r = runner.invoke(None, ["remote", "rename", "origin", "upstream", "--json"])
145 data = json.loads(r.output)
146 assert "duration_ms" in data, "rename JSON missing duration_ms"
147 assert "exit_code" in data, "rename JSON missing exit_code"
148
149 def test_II5_get_url_json_has_envelope(self, repo_with_origin: pathlib.Path) -> None:
150 """muse remote get-url --json must include duration_ms and exit_code."""
151 r = runner.invoke(None, ["remote", "get-url", "origin", "--json"])
152 data = json.loads(r.output)
153 assert "duration_ms" in data, "get-url JSON missing duration_ms"
154 assert "exit_code" in data, "get-url JSON missing exit_code"
155
156 def test_II6_set_url_json_has_envelope(self, repo_with_origin: pathlib.Path) -> None:
157 """muse remote set-url --json must include duration_ms and exit_code."""
158 r = runner.invoke(None, ["remote", "set-url", "origin",
159 "https://musehub.ai/gabriel/new", "--json"])
160 data = json.loads(r.output)
161 assert "duration_ms" in data, "set-url JSON missing duration_ms"
162 assert "exit_code" in data, "set-url JSON missing exit_code"
163
164
165 # ---------------------------------------------------------------------------
166 # III Integration — error JSON carries envelope
167 # ---------------------------------------------------------------------------
168
169 class TestErrorEnvelope:
170 def test_III1_add_duplicate_error_has_envelope(self, repo_with_origin: pathlib.Path) -> None:
171 """muse remote add duplicate --json error must include duration_ms and exit_code."""
172 r = runner.invoke(None, ["remote", "add", "origin",
173 "https://musehub.ai/x/y", "--json"])
174 data = json.loads(r.output)
175 assert "duration_ms" in data, "add-duplicate error JSON missing duration_ms"
176 assert "exit_code" in data, "add-duplicate error JSON missing exit_code"
177
178 def test_III2_add_invalid_name_error_has_envelope(self, repo: pathlib.Path) -> None:
179 """muse remote add invalid-name --json error must include envelope."""
180 r = runner.invoke(None, ["remote", "add", "bad name!",
181 "https://musehub.ai/x/y", "--json"])
182 data = json.loads(r.output)
183 assert "duration_ms" in data
184 assert "exit_code" in data
185
186 def test_III3_add_bad_scheme_error_has_envelope(self, repo: pathlib.Path) -> None:
187 """muse remote add ftp:// --json error must include envelope."""
188 r = runner.invoke(None, ["remote", "add", "origin",
189 "ftp://example.com/repo", "--json"])
190 data = json.loads(r.output)
191 assert "duration_ms" in data
192 assert "exit_code" in data
193
194 def test_III4_remove_not_found_error_has_envelope(self, repo: pathlib.Path) -> None:
195 """muse remote remove missing --json error must include envelope."""
196 r = runner.invoke(None, ["remote", "remove", "ghost", "--json"])
197 data = json.loads(r.output)
198 assert "duration_ms" in data
199 assert "exit_code" in data
200
201 def test_III5_rename_not_found_error_has_envelope(self, repo: pathlib.Path) -> None:
202 """muse remote rename missing --json error must include envelope."""
203 r = runner.invoke(None, ["remote", "rename", "ghost", "phantom", "--json"])
204 data = json.loads(r.output)
205 assert "duration_ms" in data
206 assert "exit_code" in data
207
208 def test_III6_get_url_not_found_error_has_envelope(self, repo: pathlib.Path) -> None:
209 """muse remote get-url missing --json error must include envelope."""
210 r = runner.invoke(None, ["remote", "get-url", "ghost", "--json"])
211 data = json.loads(r.output)
212 assert "duration_ms" in data
213 assert "exit_code" in data
214
215 def test_III7_set_url_not_found_error_has_envelope(self, repo: pathlib.Path) -> None:
216 """muse remote set-url missing --json error must include envelope."""
217 r = runner.invoke(None, ["remote", "set-url", "ghost",
218 "https://musehub.ai/x/y", "--json"])
219 data = json.loads(r.output)
220 assert "duration_ms" in data
221 assert "exit_code" in data
222
223
224 # ---------------------------------------------------------------------------
225 # IV End-to-end — exit_code in JSON matches process exit code
226 # ---------------------------------------------------------------------------
227
228 class TestExitCodeAccuracy:
229 def test_IV1_add_success_exit_code_is_0(self, repo: pathlib.Path) -> None:
230 """exit_code: 0 in JSON on successful add."""
231 r = runner.invoke(None, ["remote", "add", "origin",
232 "https://musehub.ai/gabriel/test", "--json"])
233 assert r.exit_code == 0
234 assert json.loads(r.output)["exit_code"] == 0
235
236 def test_IV2_add_error_exit_code_is_1(self, repo_with_origin: pathlib.Path) -> None:
237 """exit_code: 1 in JSON when add fails (duplicate)."""
238 r = runner.invoke(None, ["remote", "add", "origin",
239 "https://musehub.ai/x/y", "--json"])
240 assert r.exit_code == 1
241 assert json.loads(r.output)["exit_code"] == 1
242
243 def test_IV3_remove_success_exit_code_is_0(self, repo_with_origin: pathlib.Path) -> None:
244 """exit_code: 0 in JSON on successful remove."""
245 r = runner.invoke(None, ["remote", "remove", "origin", "--json"])
246 assert r.exit_code == 0
247 assert json.loads(r.output)["exit_code"] == 0
248
249 def test_IV4_remove_error_exit_code_is_1(self, repo: pathlib.Path) -> None:
250 """exit_code: 1 in JSON when remove fails (not found)."""
251 r = runner.invoke(None, ["remote", "remove", "ghost", "--json"])
252 assert r.exit_code == 1
253 assert json.loads(r.output)["exit_code"] == 1
254
255 def test_IV5_rename_success_exit_code_is_0(self, repo_with_origin: pathlib.Path) -> None:
256 """exit_code: 0 in JSON on successful rename."""
257 r = runner.invoke(None, ["remote", "rename", "origin", "upstream", "--json"])
258 assert r.exit_code == 0
259 assert json.loads(r.output)["exit_code"] == 0
260
261 def test_IV6_get_url_success_exit_code_is_0(self, repo_with_origin: pathlib.Path) -> None:
262 """exit_code: 0 in JSON on successful get-url."""
263 r = runner.invoke(None, ["remote", "get-url", "origin", "--json"])
264 assert r.exit_code == 0
265 assert json.loads(r.output)["exit_code"] == 0
266
267 def test_IV7_set_url_success_exit_code_is_0(self, repo_with_origin: pathlib.Path) -> None:
268 """exit_code: 0 in JSON on successful set-url."""
269 r = runner.invoke(None, ["remote", "set-url", "origin",
270 "https://musehub.ai/gabriel/new", "--json"])
271 assert r.exit_code == 0
272 assert json.loads(r.output)["exit_code"] == 0
273
274 def test_IV8_list_success_exit_code_is_0(self, repo: pathlib.Path) -> None:
275 """exit_code: 0 in JSON on successful list (even empty)."""
276 r = runner.invoke(None, ["remote", "--json"])
277 assert r.exit_code == 0
278 assert json.loads(r.output)["exit_code"] == 0
279
280
281 # ---------------------------------------------------------------------------
282 # V Data integrity — field types and values
283 # ---------------------------------------------------------------------------
284
285 class TestEnvelopeTypes:
286 def test_V1_duration_ms_is_non_negative_int_on_add(self, repo: pathlib.Path) -> None:
287 """duration_ms must be a non-negative integer."""
288 r = runner.invoke(None, ["remote", "add", "origin",
289 "https://musehub.ai/gabriel/test", "--json"])
290 data = json.loads(r.output)
291 assert isinstance(data["duration_ms"], int), "duration_ms must be int"
292 assert data["duration_ms"] >= 0, "duration_ms must be non-negative"
293
294 def test_V2_duration_ms_is_non_negative_int_on_list(self, repo: pathlib.Path) -> None:
295 """duration_ms on list is a non-negative int."""
296 r = runner.invoke(None, ["remote", "--json"])
297 data = json.loads(r.output)
298 assert isinstance(data["duration_ms"], int)
299 assert data["duration_ms"] >= 0
300
301 def test_V3_duration_ms_is_non_negative_int_on_error(
302 self, repo: pathlib.Path
303 ) -> None:
304 """duration_ms on error path is a non-negative int."""
305 r = runner.invoke(None, ["remote", "remove", "ghost", "--json"])
306 data = json.loads(r.output)
307 assert isinstance(data["duration_ms"], int)
308 assert data["duration_ms"] >= 0
309
310 def test_V4_exit_code_is_int(self, repo: pathlib.Path) -> None:
311 """exit_code must be a plain int in JSON."""
312 r = runner.invoke(None, ["remote", "--json"])
313 data = json.loads(r.output)
314 assert isinstance(data["exit_code"], int)
315
316 def test_V5_invalid_name_exit_code_matches(self, repo: pathlib.Path) -> None:
317 """exit_code in JSON matches actual exit code for invalid-name errors."""
318 r = runner.invoke(None, ["remote", "add", "bad/name",
319 "https://musehub.ai/x/y", "--json"])
320 data = json.loads(r.output)
321 assert data["exit_code"] == r.exit_code
322
323 def test_V6_all_success_fields_present_add(self, repo: pathlib.Path) -> None:
324 """add success JSON has all documented fields including envelope."""
325 r = runner.invoke(None, ["remote", "add", "origin",
326 "https://musehub.ai/gabriel/test", "--json"])
327 data = json.loads(r.output)
328 for field in ("status", "name", "url", "old_url", "old_name",
329 "new_name", "duration_ms", "exit_code"):
330 assert field in data, f"add JSON missing field: {field}"
331
332 def test_V7_all_success_fields_present_get_url(
333 self, repo_with_origin: pathlib.Path
334 ) -> None:
335 """get-url success JSON has all documented fields including envelope."""
336 r = runner.invoke(None, ["remote", "get-url", "origin", "--json"])
337 data = json.loads(r.output)
338 for field in ("name", "url", "duration_ms", "exit_code"):
339 assert field in data, f"get-url JSON missing field: {field}"
340
341 def test_V8_all_success_fields_present_list(self, repo: pathlib.Path) -> None:
342 """list success JSON has all documented fields including envelope."""
343 r = runner.invoke(None, ["remote", "--json"])
344 data = json.loads(r.output)
345 for field in ("remotes", "duration_ms", "exit_code"):
346 assert field in data, f"list JSON missing field: {field}"
347
348
349 # ---------------------------------------------------------------------------
350 # VI Security — envelope present even on adversarial inputs
351 # ---------------------------------------------------------------------------
352
353 class TestSecurityEnvelope:
354 def test_VI1_ansi_in_name_error_has_envelope(self, repo: pathlib.Path) -> None:
355 """ANSI-injected remote name error JSON has envelope."""
356 r = runner.invoke(None, ["remote", "add", "\x1b[31mevil",
357 "https://musehub.ai/x/y", "--json"])
358 data = json.loads(r.output)
359 assert "duration_ms" in data
360 assert "exit_code" in data
361 assert data["exit_code"] != 0
362
363 def test_VI2_file_scheme_error_has_envelope(self, repo: pathlib.Path) -> None:
364 """file:// URL scheme rejection carries envelope."""
365 r = runner.invoke(None, ["remote", "add", "evil",
366 "file:///etc/passwd", "--json"])
367 data = json.loads(r.output)
368 assert "duration_ms" in data
369 assert "exit_code" in data
370 assert data["exit_code"] != 0
371
372 def test_VI3_oversized_name_error_has_envelope(self, repo: pathlib.Path) -> None:
373 """Oversized remote name error JSON has envelope."""
374 long_name = "a" * 101
375 r = runner.invoke(None, ["remote", "add", long_name,
376 "https://musehub.ai/x/y", "--json"])
377 data = json.loads(r.output)
378 assert "duration_ms" in data
379 assert "exit_code" in data
380
381 def test_VI4_oversized_url_error_has_envelope(self, repo: pathlib.Path) -> None:
382 """Oversized URL error JSON has envelope."""
383 long_url = "https://musehub.ai/" + "a" * 2048
384 r = runner.invoke(None, ["remote", "add", "origin", long_url, "--json"])
385 data = json.loads(r.output)
386 assert "duration_ms" in data
387 assert "exit_code" in data
388
389 def test_VI5_rename_new_name_invalid_error_has_envelope(
390 self, repo_with_origin: pathlib.Path
391 ) -> None:
392 """Invalid new name in rename error JSON has envelope."""
393 r = runner.invoke(None, ["remote", "rename", "origin",
394 "bad name!", "--json"])
395 data = json.loads(r.output)
396 assert "duration_ms" in data
397 assert "exit_code" in data
398
399
400 # ---------------------------------------------------------------------------
401 # VII Performance — local subcommands complete quickly
402 # ---------------------------------------------------------------------------
403
404 class TestPerformance:
405 _LIMIT_MS = 200
406
407 def test_VII1_add_completes_within_limit(self, repo: pathlib.Path) -> None:
408 """muse remote add --json completes within 200 ms."""
409 r = runner.invoke(None, ["remote", "add", "origin",
410 "https://musehub.ai/gabriel/test", "--json"])
411 data = json.loads(r.output)
412 assert data["duration_ms"] <= self._LIMIT_MS, (
413 f"add took {data['duration_ms']} ms — exceeds {self._LIMIT_MS} ms limit"
414 )
415
416 def test_VII2_list_completes_within_limit(self, repo: pathlib.Path) -> None:
417 """muse remote --json completes within 200 ms."""
418 r = runner.invoke(None, ["remote", "--json"])
419 data = json.loads(r.output)
420 assert data["duration_ms"] <= self._LIMIT_MS, (
421 f"list took {data['duration_ms']} ms — exceeds {self._LIMIT_MS} ms limit"
422 )
423
424 def test_VII3_remove_completes_within_limit(
425 self, repo_with_origin: pathlib.Path
426 ) -> None:
427 """muse remote remove --json completes within 200 ms."""
428 r = runner.invoke(None, ["remote", "remove", "origin", "--json"])
429 data = json.loads(r.output)
430 assert data["duration_ms"] <= self._LIMIT_MS
431
432 def test_VII4_get_url_completes_within_limit(
433 self, repo_with_origin: pathlib.Path
434 ) -> None:
435 """muse remote get-url --json completes within 200 ms."""
436 r = runner.invoke(None, ["remote", "get-url", "origin", "--json"])
437 data = json.loads(r.output)
438 assert data["duration_ms"] <= self._LIMIT_MS
439
440 def test_VII5_set_url_completes_within_limit(
441 self, repo_with_origin: pathlib.Path
442 ) -> None:
443 """muse remote set-url --json completes within 200 ms."""
444 r = runner.invoke(None, ["remote", "set-url", "origin",
445 "https://musehub.ai/gabriel/new", "--json"])
446 data = json.loads(r.output)
447 assert data["duration_ms"] <= self._LIMIT_MS
448
449 def test_VII6_rename_completes_within_limit(
450 self, repo_with_origin: pathlib.Path
451 ) -> None:
452 """muse remote rename --json completes within 200 ms."""
453 r = runner.invoke(None, ["remote", "rename", "origin", "upstream", "--json"])
454 data = json.loads(r.output)
455 assert data["duration_ms"] <= self._LIMIT_MS
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 141 days ago