gabriel / muse public
test_workspace_supercharge.py python
533 lines 20.0 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
1 """Supercharge tests for ``muse workspace`` — agent-usability, coverage gaps.
2
3 Coverage matrix
4 ---------------
5 - duration_ms: every JSON-outputting subcommand includes it
6 - exit_code: every JSON-outputting subcommand includes it
7 - branch_mismatch: boolean flag in member JSON when actual_branch != branch
8 - list/status envelopes: {members, exit_code, duration_ms} — not bare arrays
9 - sync exit_code: reflects error_count > 0
10 - TypedDicts: verify fields exist in class annotations
11 - Docstrings: sync docstring covers JSON envelope fields
12 - Performance: duration_ms reported within reasonable bounds
13 """
14
15 from __future__ import annotations
16
17 import argparse
18 import json
19 import pathlib
20 import subprocess
21 import sys
22
23 import pytest
24
25 from muse.cli.commands.workspace import (
26 _WorkspaceAddJson,
27 _WorkspaceMemberJson,
28 _WorkspaceRemoveJson,
29 _WorkspaceSyncJson,
30 _WorkspaceUpdateJson,
31 _member_to_json,
32 run_workspace_sync,
33 )
34 from muse.core.types import NULL_COMMIT_ID
35 from muse.core.paths import muse_dir
36 from muse.core.workspace import (
37 WorkspaceMemberStatus,
38 add_workspace_member,
39 )
40
41
42 # ---------------------------------------------------------------------------
43 # Helpers
44 # ---------------------------------------------------------------------------
45
46
47 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
48 dot_muse = muse_dir(tmp_path)
49 for d in ("objects", "commits", "snapshots", "refs/heads"):
50 (dot_muse / d).mkdir(parents=True, exist_ok=True)
51 (dot_muse / "repo.json").write_text(json.dumps({"repo_id": "test-repo"}))
52 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
53 (dot_muse / "refs" / "heads" / "main").write_text(NULL_COMMIT_ID)
54 return tmp_path
55
56
57 def _cli(args: list[str], cwd: pathlib.Path) -> tuple[str, str, int]:
58 result = subprocess.run(
59 [sys.executable, "-m", "muse.cli.app"] + args,
60 capture_output=True,
61 text=True,
62 cwd=str(cwd),
63 )
64 return result.stdout, result.stderr, result.returncode
65
66
67 def _add(repo: pathlib.Path, name: str = "core", url: str = "https://musehub.ai/acme/core") -> None:
68 add_workspace_member(repo, name, url)
69
70
71 def _fake_member(
72 *,
73 present: bool = False,
74 actual_branch: str | None = None,
75 branch: str = "main",
76 ) -> WorkspaceMemberStatus:
77 return WorkspaceMemberStatus(
78 name="core",
79 url="https://musehub.ai/acme/core",
80 path=pathlib.Path("/tmp/core"),
81 branch=branch,
82 present=present,
83 head_commit=None,
84 dirty=False,
85 actual_branch=actual_branch,
86 shelf_count=0,
87 feature_branches=[],
88 )
89
90
91 # ---------------------------------------------------------------------------
92 # duration_ms — every JSON subcommand must include it
93 # ---------------------------------------------------------------------------
94
95
96 class TestDurationMs:
97
98 def test_add_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
99 repo = _make_repo(tmp_path)
100 out, _, rc = _cli(["workspace", "add", "core", "https://musehub.ai/acme/core", "--json"], repo)
101 assert rc == 0
102 data = json.loads(out)
103 assert "duration_ms" in data
104
105 def test_update_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
106 repo = _make_repo(tmp_path)
107 _add(repo)
108 out, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "--json"], repo)
109 assert rc == 0
110 data = json.loads(out)
111 assert "duration_ms" in data
112
113 def test_remove_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
114 repo = _make_repo(tmp_path)
115 _add(repo)
116 out, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
117 assert rc == 0
118 data = json.loads(out)
119 assert "duration_ms" in data
120
121 def test_list_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
122 repo = _make_repo(tmp_path)
123 _add(repo)
124 out, _, rc = _cli(["workspace", "list", "--json"], repo)
125 assert rc == 0
126 data = json.loads(out)
127 assert "duration_ms" in data
128
129 def test_status_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
130 repo = _make_repo(tmp_path)
131 _add(repo)
132 out, _, rc = _cli(["workspace", "status", "--json"], repo)
133 assert rc == 0
134 data = json.loads(out)
135 assert "duration_ms" in data
136
137 def test_sync_json_has_duration_ms(self, tmp_path: pathlib.Path) -> None:
138 repo = _make_repo(tmp_path)
139 _add(repo)
140 out, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
141 assert rc == 0
142 data = json.loads(out)
143 assert "duration_ms" in data
144
145 def test_duration_ms_is_float(self, tmp_path: pathlib.Path) -> None:
146 repo = _make_repo(tmp_path)
147 _add(repo)
148 out, _, _ = _cli(["workspace", "list", "--json"], repo)
149 data = json.loads(out)
150 assert isinstance(data["duration_ms"], (int, float))
151
152 def test_duration_ms_is_non_negative(self, tmp_path: pathlib.Path) -> None:
153 repo = _make_repo(tmp_path)
154 _add(repo)
155 out, _, _ = _cli(["workspace", "list", "--json"], repo)
156 data = json.loads(out)
157 assert data["duration_ms"] >= 0
158
159
160 # ---------------------------------------------------------------------------
161 # exit_code — every JSON subcommand must include it
162 # ---------------------------------------------------------------------------
163
164
165 class TestExitCode:
166
167 def test_add_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
168 repo = _make_repo(tmp_path)
169 out, _, rc = _cli(["workspace", "add", "core", "https://musehub.ai/acme/core", "--json"], repo)
170 assert rc == 0
171 data = json.loads(out)
172 assert "exit_code" in data
173
174 def test_update_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
175 repo = _make_repo(tmp_path)
176 _add(repo)
177 out, _, rc = _cli(["workspace", "update", "core", "--branch", "dev", "--json"], repo)
178 assert rc == 0
179 data = json.loads(out)
180 assert "exit_code" in data
181
182 def test_remove_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
183 repo = _make_repo(tmp_path)
184 _add(repo)
185 out, _, rc = _cli(["workspace", "remove", "core", "--json"], repo)
186 assert rc == 0
187 data = json.loads(out)
188 assert "exit_code" in data
189
190 def test_list_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
191 repo = _make_repo(tmp_path)
192 out, _, rc = _cli(["workspace", "list", "--json"], repo)
193 assert rc == 0
194 data = json.loads(out)
195 assert "exit_code" in data
196
197 def test_status_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
198 repo = _make_repo(tmp_path)
199 out, _, rc = _cli(["workspace", "status", "--json"], repo)
200 assert rc == 0
201 data = json.loads(out)
202 assert "exit_code" in data
203
204 def test_sync_json_has_exit_code(self, tmp_path: pathlib.Path) -> None:
205 repo = _make_repo(tmp_path)
206 out, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
207 assert rc == 0
208 data = json.loads(out)
209 assert "exit_code" in data
210
211 def test_add_exit_code_zero_on_success(self, tmp_path: pathlib.Path) -> None:
212 repo = _make_repo(tmp_path)
213 out, _, rc = _cli(["workspace", "add", "core", "https://musehub.ai/acme/core", "--json"], repo)
214 assert rc == 0
215 assert json.loads(out)["exit_code"] == 0
216
217 def test_add_exit_code_one_on_duplicate(self, tmp_path: pathlib.Path) -> None:
218 repo = _make_repo(tmp_path)
219 _add(repo)
220 out, _, rc = _cli(["workspace", "add", "core", "https://musehub.ai/acme/core", "--json"], repo)
221 assert rc == 1
222 data = json.loads(out)
223 assert data["exit_code"] == 1
224
225 def test_list_exit_code_zero_empty(self, tmp_path: pathlib.Path) -> None:
226 repo = _make_repo(tmp_path)
227 out, _, rc = _cli(["workspace", "list", "--json"], repo)
228 assert rc == 0
229 assert json.loads(out)["exit_code"] == 0
230
231 def test_exit_code_is_int(self, tmp_path: pathlib.Path) -> None:
232 repo = _make_repo(tmp_path)
233 out, _, _ = _cli(["workspace", "list", "--json"], repo)
234 data = json.loads(out)
235 assert isinstance(data["exit_code"], int)
236
237 def test_exit_code_mirrors_process_exit(self, tmp_path: pathlib.Path) -> None:
238 """exit_code in JSON must equal the process exit code."""
239 repo = _make_repo(tmp_path)
240 _add(repo)
241 out, _, rc = _cli(["workspace", "add", "core", "https://x.com/y", "--json"], repo)
242 data = json.loads(out)
243 assert data["exit_code"] == rc
244
245
246 # ---------------------------------------------------------------------------
247 # List/Status envelopes — {members, exit_code, duration_ms}, not bare arrays
248 # ---------------------------------------------------------------------------
249
250
251 class TestListEnvelope:
252
253 def test_list_json_is_dict_not_array(self, tmp_path: pathlib.Path) -> None:
254 repo = _make_repo(tmp_path)
255 out, _, _ = _cli(["workspace", "list", "--json"], repo)
256 data = json.loads(out)
257 assert isinstance(data, dict), "list --json must return an envelope dict, not a bare array"
258
259 def test_list_json_has_members_key(self, tmp_path: pathlib.Path) -> None:
260 repo = _make_repo(tmp_path)
261 _add(repo)
262 out, _, _ = _cli(["workspace", "list", "--json"], repo)
263 data = json.loads(out)
264 assert "members" in data
265
266 def test_list_json_members_is_array(self, tmp_path: pathlib.Path) -> None:
267 repo = _make_repo(tmp_path)
268 _add(repo)
269 out, _, _ = _cli(["workspace", "list", "--json"], repo)
270 data = json.loads(out)
271 assert isinstance(data["members"], list)
272
273 def test_list_json_members_count_matches_registered(self, tmp_path: pathlib.Path) -> None:
274 repo = _make_repo(tmp_path)
275 _add(repo, "core")
276 _add(repo, "sounds", "https://musehub.ai/acme/sounds")
277 out, _, _ = _cli(["workspace", "list", "--json"], repo)
278 data = json.loads(out)
279 assert len(data["members"]) == 2
280
281 def test_list_json_empty_envelope(self, tmp_path: pathlib.Path) -> None:
282 repo = _make_repo(tmp_path)
283 out, _, rc = _cli(["workspace", "list", "--json"], repo)
284 assert rc == 0
285 data = json.loads(out)
286 assert data["members"] == []
287 assert data["exit_code"] == 0
288
289 def test_list_json_member_fields_intact(self, tmp_path: pathlib.Path) -> None:
290 repo = _make_repo(tmp_path)
291 _add(repo)
292 out, _, _ = _cli(["workspace", "list", "--json"], repo)
293 member = json.loads(out)["members"][0]
294 for key in ("name", "url", "path", "branch", "present", "head_commit", "dirty",
295 "actual_branch", "shelf_count", "feature_branches"):
296 assert key in member, f"member missing key: {key}"
297
298
299 class TestStatusEnvelope:
300
301 def test_status_json_is_dict_not_array(self, tmp_path: pathlib.Path) -> None:
302 repo = _make_repo(tmp_path)
303 out, _, _ = _cli(["workspace", "status", "--json"], repo)
304 data = json.loads(out)
305 assert isinstance(data, dict), "status --json must return an envelope dict, not a bare array"
306
307 def test_status_json_has_members_key(self, tmp_path: pathlib.Path) -> None:
308 repo = _make_repo(tmp_path)
309 _add(repo)
310 out, _, _ = _cli(["workspace", "status", "--json"], repo)
311 data = json.loads(out)
312 assert "members" in data
313
314 def test_status_json_members_is_array(self, tmp_path: pathlib.Path) -> None:
315 repo = _make_repo(tmp_path)
316 _add(repo)
317 out, _, _ = _cli(["workspace", "status", "--json"], repo)
318 data = json.loads(out)
319 assert isinstance(data["members"], list)
320
321 def test_status_named_json_is_dict(self, tmp_path: pathlib.Path) -> None:
322 repo = _make_repo(tmp_path)
323 _add(repo)
324 out, _, _ = _cli(["workspace", "status", "core", "--json"], repo)
325 data = json.loads(out)
326 assert isinstance(data, dict)
327 assert "members" in data
328 assert len(data["members"]) == 1
329
330 def test_status_json_empty_envelope(self, tmp_path: pathlib.Path) -> None:
331 repo = _make_repo(tmp_path)
332 out, _, rc = _cli(["workspace", "status", "--json"], repo)
333 assert rc == 0
334 data = json.loads(out)
335 assert data["members"] == []
336 assert data["exit_code"] == 0
337
338
339 # ---------------------------------------------------------------------------
340 # branch_mismatch — bool flag in member JSON
341 # ---------------------------------------------------------------------------
342
343
344 class TestBranchMismatch:
345
346 def test_branch_mismatch_field_in_member_to_json(self) -> None:
347 m = _fake_member(branch="main", actual_branch="main")
348 result = _member_to_json(m)
349 assert "branch_mismatch" in result
350
351 def test_branch_mismatch_false_when_on_tracking_branch(self) -> None:
352 m = _fake_member(branch="main", actual_branch="main")
353 result = _member_to_json(m)
354 assert result["branch_mismatch"] is False
355
356 def test_branch_mismatch_true_when_on_different_branch(self) -> None:
357 m = _fake_member(branch="main", actual_branch="dev")
358 result = _member_to_json(m)
359 assert result["branch_mismatch"] is True
360
361 def test_branch_mismatch_false_when_not_present(self) -> None:
362 """Not-cloned members have no actual branch — no mismatch."""
363 m = _fake_member(present=False, actual_branch=None, branch="main")
364 result = _member_to_json(m)
365 assert result["branch_mismatch"] is False
366
367 def test_branch_mismatch_is_bool(self) -> None:
368 m = _fake_member(branch="main", actual_branch="main")
369 result = _member_to_json(m)
370 assert isinstance(result["branch_mismatch"], bool)
371
372 def test_branch_mismatch_in_list_json(self, tmp_path: pathlib.Path) -> None:
373 repo = _make_repo(tmp_path)
374 _add(repo)
375 out, _, _ = _cli(["workspace", "list", "--json"], repo)
376 member = json.loads(out)["members"][0]
377 assert "branch_mismatch" in member
378 assert isinstance(member["branch_mismatch"], bool)
379
380 def test_branch_mismatch_in_status_json(self, tmp_path: pathlib.Path) -> None:
381 repo = _make_repo(tmp_path)
382 _add(repo)
383 out, _, _ = _cli(["workspace", "status", "--json"], repo)
384 member = json.loads(out)["members"][0]
385 assert "branch_mismatch" in member
386
387
388 # ---------------------------------------------------------------------------
389 # sync exit_code reflects error_count
390 # ---------------------------------------------------------------------------
391
392
393 class TestSyncExitCode:
394
395 def test_sync_dry_run_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
396 repo = _make_repo(tmp_path)
397 _add(repo)
398 out, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
399 assert rc == 0
400 data = json.loads(out)
401 assert data["exit_code"] == 0
402
403 def test_sync_empty_manifest_exit_code_zero(self, tmp_path: pathlib.Path) -> None:
404 repo = _make_repo(tmp_path)
405 out, _, rc = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
406 assert rc == 0
407 data = json.loads(out)
408 assert data["exit_code"] == 0
409
410 def test_sync_exit_code_is_int(self, tmp_path: pathlib.Path) -> None:
411 repo = _make_repo(tmp_path)
412 out, _, _ = _cli(["workspace", "sync", "--dry-run", "--json"], repo)
413 data = json.loads(out)
414 assert isinstance(data["exit_code"], int)
415
416
417 # ---------------------------------------------------------------------------
418 # TypedDicts — verify annotations carry the new fields
419 # ---------------------------------------------------------------------------
420
421
422 class TestTypedDicts:
423
424 def test_workspace_add_json_has_duration_ms_annotation(self) -> None:
425 assert "duration_ms" in _WorkspaceAddJson.__annotations__
426
427 def test_workspace_add_json_has_exit_code_annotation(self) -> None:
428 assert "exit_code" in _WorkspaceAddJson.__annotations__
429
430 def test_workspace_update_json_has_duration_ms_annotation(self) -> None:
431 assert "duration_ms" in _WorkspaceUpdateJson.__annotations__
432
433 def test_workspace_update_json_has_exit_code_annotation(self) -> None:
434 assert "exit_code" in _WorkspaceUpdateJson.__annotations__
435
436 def test_workspace_remove_json_has_duration_ms_annotation(self) -> None:
437 assert "duration_ms" in _WorkspaceRemoveJson.__annotations__
438
439 def test_workspace_remove_json_has_exit_code_annotation(self) -> None:
440 assert "exit_code" in _WorkspaceRemoveJson.__annotations__
441
442 def test_workspace_sync_json_has_duration_ms_annotation(self) -> None:
443 assert "duration_ms" in _WorkspaceSyncJson.__annotations__
444
445 def test_workspace_sync_json_has_exit_code_annotation(self) -> None:
446 assert "exit_code" in _WorkspaceSyncJson.__annotations__
447
448 def test_workspace_member_json_has_branch_mismatch_annotation(self) -> None:
449 assert "branch_mismatch" in _WorkspaceMemberJson.__annotations__
450
451
452 # ---------------------------------------------------------------------------
453 # Docstrings
454 # ---------------------------------------------------------------------------
455
456
457 class TestDocstrings:
458
459 def test_sync_docstring_mentions_workers(self) -> None:
460 assert "workers" in (run_workspace_sync.__doc__ or "").lower()
461
462 def test_sync_docstring_mentions_dry_run(self) -> None:
463 assert "dry" in (run_workspace_sync.__doc__ or "").lower()
464
465 def test_sync_docstring_mentions_json_fields(self) -> None:
466 doc = run_workspace_sync.__doc__ or ""
467 assert "exit_code" in doc or "duration_ms" in doc or "json" in doc.lower()
468
469 def test_sync_docstring_mentions_exit_code(self) -> None:
470 assert "exit_code" in (run_workspace_sync.__doc__ or "")
471
472 def test_sync_docstring_mentions_duration_ms(self) -> None:
473 assert "duration_ms" in (run_workspace_sync.__doc__ or "")
474
475
476 # ---------------------------------------------------------------------------
477 # Performance — duration_ms stays within reason
478 # ---------------------------------------------------------------------------
479
480
481 class TestPerformance:
482
483 def test_list_10_members_duration_ms_under_2000(self, tmp_path: pathlib.Path) -> None:
484 repo = _make_repo(tmp_path)
485 for i in range(10):
486 _add(repo, f"member{i}", f"https://musehub.ai/acme/m{i}")
487 out, _, rc = _cli(["workspace", "list", "--json"], repo)
488 assert rc == 0
489 data = json.loads(out)
490 assert data["duration_ms"] < 2000, f"list of 10 took {data['duration_ms']}ms"
491
492 def test_status_10_members_duration_ms_under_2000(self, tmp_path: pathlib.Path) -> None:
493 repo = _make_repo(tmp_path)
494 for i in range(10):
495 _add(repo, f"member{i}", f"https://musehub.ai/acme/m{i}")
496 out, _, rc = _cli(["workspace", "status", "--json"], repo)
497 assert rc == 0
498 data = json.loads(out)
499 assert data["duration_ms"] < 2000
500
501 def test_add_duration_ms_under_500(self, tmp_path: pathlib.Path) -> None:
502 repo = _make_repo(tmp_path)
503 out, _, rc = _cli(["workspace", "add", "core", "https://musehub.ai/acme/core", "--json"], repo)
504 assert rc == 0
505 data = json.loads(out)
506 assert data["duration_ms"] < 500
507
508
509 # ---------------------------------------------------------------------------
510 # Flag registration
511 # ---------------------------------------------------------------------------
512
513
514 class TestRegisterFlags:
515 def _parse(self, *args: str) -> "argparse.Namespace":
516 import argparse
517 from muse.cli.commands.workspace import register
518 p = argparse.ArgumentParser()
519 sub = p.add_subparsers()
520 register(sub)
521 return p.parse_args(["workspace", *args])
522
523 def test_default_json_out_is_false_add(self) -> None:
524 ns = self._parse("add", "myrepo", "https://musehub.ai/x/y")
525 assert ns.json_out is False
526
527 def test_json_flag_sets_json_out_add(self) -> None:
528 ns = self._parse("add", "myrepo", "https://musehub.ai/x/y", "--json")
529 assert ns.json_out is True
530
531 def test_j_shorthand_sets_json_out_add(self) -> None:
532 ns = self._parse("add", "myrepo", "https://musehub.ai/x/y", "-j")
533 assert ns.json_out is True
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 20 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 28 days ago