gabriel / muse public
test_cmd_show_hardening.py python
219 lines 7.8 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Hardening tests for ``muse read``.
2
3 Covers gaps identified during SUPERCHARGE review:
4
5 1. ``duration_ms`` + ``exit_code`` present in success JSON (TestElapsedAndExitCode)
6 2. commit-not-found error with ``--json`` emits structured JSON to stdout — no
7 duplicate plain-text to stderr (TestErrorJson)
8 3. Invalid ``--format`` with ``--json``-implied path emits structured error
9 4. Error JSON carries ``duration_ms`` and ``exit_code`` (TestErrorJson)
10 5. ``TestJsonSchema`` REQUIRED_KEYS updated to include ``duration_ms``/``exit_code``
11 """
12
13 from __future__ import annotations
14
15 import json
16 import os
17 import pathlib
18
19 import pytest
20
21 from tests.cli_test_helper import CliRunner, InvokeResult
22
23 runner = CliRunner()
24
25
26 # ---------------------------------------------------------------------------
27 # Helpers
28 # ---------------------------------------------------------------------------
29
30
31 def _invoke(repo: pathlib.Path, args: list[str]) -> InvokeResult:
32 saved = os.getcwd()
33 try:
34 os.chdir(repo)
35 return runner.invoke(None, args)
36 finally:
37 os.chdir(saved)
38
39
40 def _show(repo: pathlib.Path, *extra: str) -> InvokeResult:
41 return _invoke(repo, ["read", *extra])
42
43
44 def _commit(repo: pathlib.Path, *extra: str) -> InvokeResult:
45 return _invoke(repo, ["commit", *extra])
46
47
48 @pytest.fixture()
49 def repo(tmp_path: pathlib.Path) -> pathlib.Path:
50 """Initialised repo with one tracked file and one commit."""
51 saved = os.getcwd()
52 try:
53 os.chdir(tmp_path)
54 runner.invoke(None, ["init"])
55 finally:
56 os.chdir(saved)
57 (tmp_path / "a.py").write_text("x = 1\n")
58 _commit(tmp_path, "-m", "initial commit")
59 return tmp_path
60
61
62 def _assert_error_json(result: InvokeResult) -> dict:
63 """Assert result has non-zero exit code and parseable error JSON on stdout."""
64 assert result.exit_code != 0
65 d = json.loads(result.output) # must be on stdout, not stderr
66 assert "error" in d, f"'error' key missing: {d}"
67 assert "duration_ms" in d, f"'duration_ms' key missing: {d}"
68 assert "exit_code" in d, f"'exit_code' key missing: {d}"
69 assert d["exit_code"] != 0
70 return d
71
72
73 # ---------------------------------------------------------------------------
74 # TestElapsedAndExitCode — success JSON must carry envelope fields
75 # ---------------------------------------------------------------------------
76
77
78 class TestElapsedAndExitCode:
79 def test_success_json_has_duration_ms(self, repo: pathlib.Path) -> None:
80 result = _show(repo, "--json")
81 assert result.exit_code == 0
82 data = json.loads(result.output)
83 assert "duration_ms" in data, f"'duration_ms' missing from: {list(data)}"
84
85 def test_success_json_duration_ms_is_float(self, repo: pathlib.Path) -> None:
86 result = _show(repo, "--json")
87 data = json.loads(result.output)
88 assert isinstance(data["duration_ms"], float), (
89 f"duration_ms should be float, got {type(data['duration_ms'])}"
90 )
91
92 def test_success_json_duration_ms_non_negative(self, repo: pathlib.Path) -> None:
93 result = _show(repo, "--json")
94 data = json.loads(result.output)
95 assert data["duration_ms"] >= 0.0
96
97 def test_success_json_has_exit_code(self, repo: pathlib.Path) -> None:
98 result = _show(repo, "--json")
99 data = json.loads(result.output)
100 assert "exit_code" in data, f"'exit_code' missing from: {list(data)}"
101
102 def test_success_json_exit_code_is_zero(self, repo: pathlib.Path) -> None:
103 result = _show(repo, "--json")
104 data = json.loads(result.output)
105 assert data["exit_code"] == 0
106
107 def test_no_delta_json_has_envelope(self, repo: pathlib.Path) -> None:
108 result = _show(repo, "--json", "--no-delta")
109 data = json.loads(result.output)
110 assert "duration_ms" in data
111 assert "exit_code" in data
112 assert data["exit_code"] == 0
113
114 def test_manifest_json_has_envelope(self, repo: pathlib.Path) -> None:
115 result = _show(repo, "--json", "--manifest")
116 data = json.loads(result.output)
117 assert "duration_ms" in data
118 assert "exit_code" in data
119 assert data["exit_code"] == 0
120
121 def test_no_stat_json_has_envelope(self, repo: pathlib.Path) -> None:
122 result = _show(repo, "--json", "--no-stat")
123 data = json.loads(result.output)
124 assert "duration_ms" in data
125 assert "exit_code" in data
126 assert data["exit_code"] == 0
127
128
129 # ---------------------------------------------------------------------------
130 # TestErrorJson — error paths emit structured JSON to stdout
131 # ---------------------------------------------------------------------------
132
133
134 class TestErrorJson:
135 def test_commit_not_found_json_to_stdout(self, repo: pathlib.Path) -> None:
136 """commit-not-found with --json emits JSON to stdout, not stderr."""
137 result = _show(repo, "--json", "nonexistent-branch-xyz")
138 d = _assert_error_json(result)
139 assert d["error"] == "commit_not_found"
140
141 def test_commit_not_found_json_has_ref_key(self, repo: pathlib.Path) -> None:
142 result = _show(repo, "--json", "nonexistent-branch-xyz")
143 d = json.loads(result.output)
144 assert "ref" in d
145
146 def test_commit_not_found_no_duplicate_stderr(self, repo: pathlib.Path) -> None:
147 """When --json, the plain-text ❌ line must NOT also appear on stderr."""
148 result = _show(repo, "--json", "nonexistent-ref")
149 # stderr should be empty (or at most the ❌ line must NOT be present)
150 stderr = result.stderr or ""
151 assert "not found" not in stderr.lower(), (
152 f"Plain-text error leaked to stderr: {stderr!r}"
153 )
154
155 def test_commit_not_found_error_json_has_duration_ms(
156 self, repo: pathlib.Path
157 ) -> None:
158 result = _show(repo, "--json", "nonexistent")
159 d = _assert_error_json(result)
160 assert isinstance(d["duration_ms"], float)
161
162 def test_commit_not_found_error_json_exit_code_nonzero(
163 self, repo: pathlib.Path
164 ) -> None:
165 result = _show(repo, "--json", "nonexistent")
166 d = _assert_error_json(result)
167 assert d["exit_code"] == 1
168
169 def test_invalid_format_exits_1(self, repo: pathlib.Path) -> None:
170 result = _show(repo, "--format", "xml")
171 assert result.exit_code == 1
172
173 def test_invalid_format_with_json_flag_emits_error_json(
174 self, repo: pathlib.Path
175 ) -> None:
176 """When the format itself is invalid, we can't use --json (it IS --format json),
177 but the --format xml case should still emit a parseable error to stdout."""
178 # Note: --format xml is always invalid. Test that exit_code is 1 and
179 # output is either parseable error JSON or plain text to stderr.
180 result = _show(repo, "--format", "xml")
181 assert result.exit_code == 1
182 # The error must NOT contain raw ANSI escapes
183 assert "\x1b[" not in result.output
184
185
186 # ---------------------------------------------------------------------------
187 # TestRequiredKeysUpdated — existing TestJsonSchema REQUIRED_KEYS check
188 # ---------------------------------------------------------------------------
189
190
191 class TestRequiredKeysUpdated:
192 """duration_ms and exit_code must be in the success JSON schema."""
193
194 REQUIRED_KEYS = {
195 "commit_id",
196 "branch",
197 "message",
198 "author",
199 "agent_id",
200 "committed_at",
201 "snapshot_id",
202 "parent_commit_id",
203 "parent2_commit_id",
204 "sem_ver_bump",
205 "breaking_changes",
206 "metadata",
207 "files_added",
208 "files_removed",
209 "files_modified",
210 "duration_ms",
211 "exit_code",
212 }
213
214 def test_all_required_keys_present(self, repo: pathlib.Path) -> None:
215 result = _show(repo, "--json")
216 assert result.exit_code == 0
217 data = json.loads(result.output)
218 missing = self.REQUIRED_KEYS - set(data)
219 assert not missing, f"Missing JSON keys: {missing}"
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago