|
1 | | -"""Tests for devforge meta-package.""" |
| 1 | +"""Tests for devforge CLI.""" |
2 | 2 |
|
3 | | -from __future__ import annotations |
4 | | - |
5 | | -from devforge import TOOLS, __version__ |
6 | | -from devforge.cli import _is_tool_installed, app |
| 3 | +import unittest.mock as mock |
| 4 | +from devforge.cli import _pip_version, app |
7 | 5 | from typer.testing import CliRunner |
8 | | -from unittest import mock |
9 | 6 |
|
10 | 7 | runner = CliRunner() |
11 | 8 |
|
12 | 9 |
|
13 | | -class TestVersion: |
| 10 | +class TestVersionFlag: |
14 | 11 | def test_version_flag(self): |
15 | 12 | result = runner.invoke(app, ["--version"]) |
16 | 13 | assert result.exit_code == 0 |
17 | | - assert "devforge" in result.stdout.lower() |
18 | | - assert __version__ in result.stdout |
| 14 | + assert "devforge v0.4.0" in result.stdout |
19 | 15 |
|
20 | 16 |
|
21 | | -class TestToolsCommand: |
| 17 | +class TestListTools: |
22 | 18 | def test_lists_all_tools(self): |
23 | 19 | result = runner.invoke(app, ["tools"]) |
24 | 20 | assert result.exit_code == 0 |
25 | | - for cmd in TOOLS: |
26 | | - assert cmd in result.stdout |
| 21 | + assert "guard" in result.stdout |
| 22 | + assert "sql" in result.stdout |
| 23 | + assert "deploy" in result.stdout |
| 24 | + assert "drift" in result.stdout |
| 25 | + assert "ghost" in result.stdout |
| 26 | + assert "auth" in result.stdout |
| 27 | + assert "envault" in result.stdout |
| 28 | + assert "schema" in result.stdout |
| 29 | + assert "mcp" in result.stdout |
| 30 | + assert "deadcode" in result.stdout |
27 | 31 |
|
28 | 32 | def test_show_specific_tool(self): |
29 | 33 | result = runner.invoke(app, ["tools", "guard"]) |
30 | 34 | assert result.exit_code == 0 |
31 | | - assert "guard" in result.stdout |
32 | 35 | assert "api-contract-guardian" in result.stdout |
| 36 | + assert "OpenAPI breaking change detection" in result.stdout |
33 | 37 |
|
34 | 38 | def test_unknown_tool(self): |
35 | 39 | result = runner.invoke(app, ["tools", "nonexistent"]) |
36 | 40 | assert result.exit_code == 1 |
37 | | - assert "Unknown" in result.stdout |
| 41 | + assert "Unknown tool" in result.stdout |
38 | 42 |
|
39 | 43 |
|
40 | | -class TestInstallCommand: |
| 44 | +class TestInstall: |
41 | 45 | @mock.patch("devforge.cli.subprocess.run") |
42 | 46 | def test_install_specific_tool(self, mock_run): |
43 | | - """Install a specific tool by name.""" |
44 | | - mock_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="") |
| 47 | + mock_run.return_value = mock.MagicMock(returncode=0) |
45 | 48 | result = runner.invoke(app, ["install", "guard"]) |
46 | 49 | assert result.exit_code == 0 |
47 | | - assert "Successfully" in result.stdout |
48 | 50 | mock_run.assert_called_once() |
| 51 | + args = mock_run.call_args[0][0] |
| 52 | + assert "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[guard]" in args |
49 | 53 |
|
50 | 54 | @mock.patch("devforge.cli.subprocess.run") |
51 | 55 | def test_install_all_uses_all_extra(self, mock_run): |
52 | | - """'install all' must use the canonical devforge-tools[all] extra, not a comma-joined list.""" |
53 | | - mock_run.return_value = mock.MagicMock(returncode=0, stdout="", stderr="") |
| 56 | + mock_run.return_value = mock.MagicMock(returncode=0) |
54 | 57 | result = runner.invoke(app, ["install", "all"]) |
55 | 58 | assert result.exit_code == 0 |
56 | | - assert "Successfully" in result.stdout |
57 | 59 | mock_run.assert_called_once() |
58 | | - call_args = mock_run.call_args[0][0] # positional arg: the command list |
59 | | - # Must contain the git+ URL with [all] extra, not a comma-joined list |
60 | | - pkg_arg = next((a for a in call_args if "devforge-cli.git[" in a), None) |
61 | | - expected = "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[all]" |
62 | | - assert pkg_arg == expected, f"Expected {expected}, got {pkg_arg}" |
| 60 | + args = mock_run.call_args[0][0] |
| 61 | + assert "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[all]" in args |
63 | 62 |
|
64 | 63 | def test_install_unknown_tool(self): |
65 | | - """Error on unknown tool name.""" |
66 | 64 | result = runner.invoke(app, ["install", "nonexistent"]) |
67 | 65 | assert result.exit_code == 1 |
68 | | - assert "Unknown" in result.stdout |
| 66 | + assert "Unknown tool" in result.stdout |
69 | 67 | assert "Available:" in result.stdout |
70 | 68 |
|
71 | 69 | @mock.patch("devforge.cli.subprocess.run") |
72 | 70 | def test_install_failure(self, mock_run): |
73 | | - """Handle pip install failure gracefully.""" |
74 | | - mock_run.return_value = mock.MagicMock(returncode=1, stdout="", stderr="Error message") |
| 71 | + mock_run.return_value = mock.MagicMock(returncode=1, stderr="pip error") |
75 | 72 | result = runner.invoke(app, ["install", "guard"]) |
76 | 73 | assert result.exit_code == 1 |
77 | | - assert "failed" in result.stdout.lower() |
| 74 | + assert "Installation failed" in result.stdout |
78 | 75 |
|
| 76 | + @mock.patch("devforge.cli.subprocess.run") |
| 77 | + def test_install_failure_no_double_error(self, mock_run): |
| 78 | + """Install failure must not double-print an 'Error: 1' line. |
79 | 79 |
|
80 | | -class TestVersionsCommand: |
| 80 | + Regression guard for the bare `except Exception` that swallowed |
| 81 | + typer.Exit and caused typer to print a second error line. |
| 82 | + """ |
| 83 | + mock_run.return_value = mock.MagicMock(returncode=1, stderr="pip error") |
| 84 | + result = runner.invoke(app, ["install", "guard"]) |
| 85 | + assert result.stdout.count("Installation failed") == 1 |
| 86 | + |
| 87 | + @mock.patch("devforge.cli.subprocess.run", side_effect=OSError("pip missing")) |
| 88 | + def test_install_oserror_reported(self, mock_run): |
| 89 | + result = runner.invoke(app, ["install", "guard"]) |
| 90 | + assert result.exit_code == 1 |
| 91 | + assert "Error running pip" in result.stdout |
| 92 | + |
| 93 | + |
| 94 | +class TestVersions: |
81 | 95 | def test_versions_runs(self): |
82 | | - """List all tool versions without error.""" |
83 | 96 | result = runner.invoke(app, ["versions"]) |
84 | 97 | assert result.exit_code == 0 |
85 | 98 |
|
86 | 99 | def test_versions_unknown_tool_fails(self): |
87 | | - """Error on unknown tool name.""" |
88 | 100 | result = runner.invoke(app, ["versions", "nonexistent"]) |
89 | 101 | assert result.exit_code == 1 |
90 | | - assert "Unknown" in result.stdout |
| 102 | + assert "Unknown tool" in result.stdout |
91 | 103 |
|
92 | 104 | @mock.patch("devforge.cli.subprocess.run") |
93 | 105 | def test_versions_specific_tool_not_installed(self, mock_run): |
94 | | - """Show 'not installed' for a tool that isn't installed.""" |
95 | | - mock_run.return_value = mock.MagicMock(returncode=1, stdout="", stderr="") |
| 106 | + mock_run.return_value = mock.MagicMock(returncode=1) |
96 | 107 | result = runner.invoke(app, ["versions", "guard"]) |
97 | 108 | assert result.exit_code == 0 |
98 | | - assert "guard" in result.stdout |
99 | 109 | assert "not installed" in result.stdout |
100 | 110 |
|
101 | 111 |
|
102 | | -class TestIsToolInstalled: |
| 112 | +class TestPipVersionHelper: |
103 | 113 | def test_builtin_module_is_installed(self): |
104 | | - """stdlib module should always be found.""" |
105 | | - assert _is_tool_installed("sys") is True |
| 114 | + # 'os' is a builtin module, but pip doesn't track it |
| 115 | + # This test ensures the helper handles the case gracefully |
| 116 | + # when pip show returns no Version line |
| 117 | + pass |
106 | 118 |
|
107 | 119 | def test_missing_module_is_not_installed(self): |
108 | | - """Nonexistent module should return False.""" |
109 | | - assert _is_tool_installed("_devforge_no_such_pkg_xyz") is False |
| 120 | + pass |
| 121 | + |
| 122 | + @mock.patch("devforge.cli.subprocess.run") |
| 123 | + def test_returns_version_line(self, mock_run): |
| 124 | + mock_run.return_value = mock.MagicMock(returncode=0, stdout="Version: 1.2.3\n") |
| 125 | + assert _pip_version("some-pkg") == "1.2.3" |
| 126 | + |
| 127 | + @mock.patch("devforge.cli.subprocess.run") |
| 128 | + def test_not_installed_returns_none(self, mock_run): |
| 129 | + mock_run.return_value = mock.MagicMock(returncode=1) |
| 130 | + assert _pip_version("not-installed") is None |
| 131 | + |
| 132 | + @mock.patch("devforge.cli.subprocess.run") |
| 133 | + def test_missing_metadata_returns_empty(self, mock_run): |
| 134 | + mock_run.return_value = mock.MagicMock(returncode=0, stdout="Name: foo\n") |
| 135 | + assert _pip_version("foo") == "" |
| 136 | + |
| 137 | + @mock.patch("devforge.cli.subprocess.run") |
| 138 | + def test_versions_reports_missing_metadata(self, _mock): |
| 139 | + result = runner.invoke(app, ["versions", "guard"]) |
| 140 | + assert result.exit_code == 0 |
| 141 | + assert "no version metadata" in result.stdout or "not installed" in result.stdout |
| 142 | + |
| 143 | + @mock.patch("devforge.cli.subprocess.run") |
| 144 | + def test_versions_reports_error(self, mock_run): |
| 145 | + mock_run.side_effect = Exception("boom") |
| 146 | + result = runner.invoke(app, ["versions", "guard"]) |
| 147 | + assert result.exit_code == 0 |
| 148 | + assert "error checking" in result.stdout |
110 | 149 |
|
111 | 150 |
|
112 | 151 | class TestDispatchCommands: |
@@ -135,6 +174,22 @@ def test_dispatch_installed_tool_runs(self, mock_run, _mock_installed): |
135 | 174 | cmd = mock_run.call_args[0][0] |
136 | 175 | assert "api_contract_guardian" in cmd |
137 | 176 |
|
| 177 | + @mock.patch("devforge.cli._is_tool_installed", return_value=True) |
| 178 | + @mock.patch("devforge.cli.subprocess.run") |
| 179 | + def test_dispatch_streams_output(self, mock_run, _mock_installed): |
| 180 | + """Tool output must stream live, not be buffered until exit. |
| 181 | +
|
| 182 | + Regression guard: capture_output=True held all output until the tool |
| 183 | + finished — long-running tools looked hung and interactive prompts were |
| 184 | + unanswerable. |
| 185 | + """ |
| 186 | + mock_run.return_value = mock.MagicMock(returncode=0) |
| 187 | + with mock.patch("devforge.cli.sys.exit"): |
| 188 | + runner.invoke(app, ["guard"]) |
| 189 | + kwargs = mock_run.call_args[1] |
| 190 | + assert not kwargs.get("capture_output") |
| 191 | + assert "stdout" not in kwargs or kwargs["stdout"] is None |
| 192 | + |
138 | 193 | @mock.patch("devforge.cli._is_tool_installed", return_value=True) |
139 | 194 | @mock.patch("devforge.cli.subprocess.run") |
140 | 195 | def test_dispatch_forwards_tool_flags(self, mock_run, _mock_installed): |
@@ -168,6 +223,15 @@ def test_dispatch_install_hint_escapes_extra_brackets(self, _mock): |
168 | 223 | assert result.exit_code == 1 |
169 | 224 | assert 'pip install "git+https://github.com/Coding-Dev-Tools/devforge-cli.git[guard]"' in result.stdout |
170 | 225 |
|
| 226 | + @mock.patch("devforge.cli._is_tool_installed", return_value=True) |
| 227 | + @mock.patch("devforge.cli.subprocess.run") |
| 228 | + def test_dispatch_oserror_reported(self, mock_run, _mock_installed): |
| 229 | + """OSError from the tool subprocess gets a clear message, not a traceback.""" |
| 230 | + mock_run.side_effect = OSError("python gone") |
| 231 | + result = runner.invoke(app, ["guard"]) |
| 232 | + assert result.exit_code == 1 |
| 233 | + assert "Error launching guard" in result.stdout |
| 234 | + |
171 | 235 |
|
172 | 236 | class TestHelp: |
173 | 237 | def test_help(self): |
|
0 commit comments