|
| 1 | +"""Smoke tests for CLI commands: new, generate, serve.""" |
| 2 | + |
| 3 | +import os |
| 4 | +from pathlib import Path |
| 5 | + |
| 6 | +import pytest |
| 7 | +from click.testing import CliRunner |
| 8 | + |
| 9 | +from dspy_cli.cli import main |
| 10 | + |
| 11 | + |
| 12 | +@pytest.fixture |
| 13 | +def runner(): |
| 14 | + """Create Click CLI test runner.""" |
| 15 | + return CliRunner() |
| 16 | + |
| 17 | + |
| 18 | +@pytest.fixture |
| 19 | +def tmp_cwd(tmp_path, monkeypatch): |
| 20 | + """Change to temp directory for test, restore after.""" |
| 21 | + old = os.getcwd() |
| 22 | + os.chdir(tmp_path) |
| 23 | + try: |
| 24 | + yield tmp_path |
| 25 | + finally: |
| 26 | + os.chdir(old) |
| 27 | + |
| 28 | + |
| 29 | +def test_cli_e2e_smoke(runner, tmp_cwd, monkeypatch): |
| 30 | + """End-to-end smoke test: new -> generate -> serve. |
| 31 | + |
| 32 | + Creates project, generates components, validates serve args. |
| 33 | + """ |
| 34 | + # 1. Test 'new' command |
| 35 | + res = runner.invoke(main, ["new", "acme-app"], catch_exceptions=False) |
| 36 | + assert res.exit_code == 0 |
| 37 | + |
| 38 | + proj = tmp_cwd / "acme-app" |
| 39 | + |
| 40 | + # Verify config files created |
| 41 | + for name in ["pyproject.toml", "dspy.config.yaml", "Dockerfile", ".dockerignore", ".env", "README.md", ".gitignore"]: |
| 42 | + assert (proj / name).exists(), f"Missing {name}" |
| 43 | + |
| 44 | + # Verify code structure created |
| 45 | + assert (proj / "src" / "acme_app" / "modules" / "acme_app_predict.py").exists() |
| 46 | + assert (proj / "src" / "acme_app" / "signatures" / "acme_app.py").exists() |
| 47 | + assert (proj / "tests" / "test_modules.py").exists() |
| 48 | + |
| 49 | + # 2. Test 'generate scaffold' command |
| 50 | + os.chdir(proj) |
| 51 | + res = runner.invoke( |
| 52 | + main, |
| 53 | + ["g", "scaffold", "categorizer", "-m", "CoT", "-s", "question -> answer"], |
| 54 | + catch_exceptions=False |
| 55 | + ) |
| 56 | + assert res.exit_code == 0 |
| 57 | + assert (proj / "src" / "acme_app" / "signatures" / "categorizer.py").exists() |
| 58 | + assert (proj / "src" / "acme_app" / "modules" / "categorizer_cot.py").exists() |
| 59 | + |
| 60 | + # 3. Test 'generate signature' command |
| 61 | + res = runner.invoke( |
| 62 | + main, |
| 63 | + ["g", "signature", "tags", "-s", "post -> tags: list[str]"], |
| 64 | + catch_exceptions=False |
| 65 | + ) |
| 66 | + assert res.exit_code == 0 |
| 67 | + assert (proj / "src" / "acme_app" / "signatures" / "tags.py").exists() |
| 68 | + |
| 69 | + # 4. Test 'generate module' command |
| 70 | + res = runner.invoke( |
| 71 | + main, |
| 72 | + ["g", "module", "my_mod", "-m", "Predict"], |
| 73 | + catch_exceptions=False |
| 74 | + ) |
| 75 | + assert res.exit_code == 0 |
| 76 | + assert (proj / "src" / "acme_app" / "modules" / "my_mod_predict.py").exists() |
| 77 | + |
| 78 | + # 5. Test 'serve' command (stubbed to avoid starting actual server) |
| 79 | + calls = {} |
| 80 | + |
| 81 | + def fake_runner_main(**kwargs): |
| 82 | + calls.update(kwargs) |
| 83 | + |
| 84 | + monkeypatch.setattr("dspy_cli.commands.serve.runner_main", fake_runner_main) |
| 85 | + |
| 86 | + res = runner.invoke( |
| 87 | + main, |
| 88 | + [ |
| 89 | + "serve", |
| 90 | + "--system", |
| 91 | + "--host", "127.0.0.1", |
| 92 | + "--port", "8765", |
| 93 | + "--no-reload", |
| 94 | + "--openapi-format", "yaml", |
| 95 | + "--logs-dir", "logs", |
| 96 | + "--ui" |
| 97 | + ], |
| 98 | + catch_exceptions=False |
| 99 | + ) |
| 100 | + assert res.exit_code == 0 |
| 101 | + |
| 102 | + # Verify serve received correct arguments |
| 103 | + assert calls == { |
| 104 | + "port": 8765, |
| 105 | + "host": "127.0.0.1", |
| 106 | + "logs_dir": "logs", |
| 107 | + "ui": True, |
| 108 | + "reload": False, |
| 109 | + "save_openapi": True, |
| 110 | + "openapi_format": "yaml", |
| 111 | + "mcp": False, |
| 112 | + } |
| 113 | + |
| 114 | + |
| 115 | +def test_new_with_signature(runner, tmp_cwd): |
| 116 | + """Test 'new' command with custom signature.""" |
| 117 | + res = runner.invoke( |
| 118 | + main, |
| 119 | + ["new", "my-project", "-p", "analyzer", "-s", "text, context: list[str] -> summary"], |
| 120 | + catch_exceptions=False |
| 121 | + ) |
| 122 | + assert res.exit_code == 0 |
| 123 | + |
| 124 | + proj = tmp_cwd / "my-project" |
| 125 | + assert (proj / "src" / "my_project" / "modules" / "analyzer_predict.py").exists() |
| 126 | + assert (proj / "src" / "my_project" / "signatures" / "analyzer.py").exists() |
| 127 | + |
| 128 | + # Verify signature has correct fields |
| 129 | + sig_content = (proj / "src" / "my_project" / "signatures" / "analyzer.py").read_text() |
| 130 | + assert "text: str = dspy.InputField" in sig_content |
| 131 | + assert "context: list[str] = dspy.InputField" in sig_content |
| 132 | + assert "summary: str = dspy.OutputField" in sig_content |
| 133 | + |
| 134 | + |
| 135 | +def test_generate_different_module_types(runner, tmp_cwd): |
| 136 | + """Test generating different module types.""" |
| 137 | + # Create a project first |
| 138 | + res = runner.invoke(main, ["new", "test-app"], catch_exceptions=False) |
| 139 | + assert res.exit_code == 0 |
| 140 | + |
| 141 | + proj = tmp_cwd / "test-app" |
| 142 | + os.chdir(proj) |
| 143 | + |
| 144 | + # Test different module types |
| 145 | + test_cases = [ |
| 146 | + ("react_mod", "ReAct", "react_mod_react.py"), |
| 147 | + ("pot_mod", "PoT", "pot_mod_pot.py"), |
| 148 | + ("refine_mod", "Refine", "refine_mod_refine.py"), |
| 149 | + ] |
| 150 | + |
| 151 | + for prog_name, mod_type, expected_file in test_cases: |
| 152 | + res = runner.invoke( |
| 153 | + main, |
| 154 | + ["g", "module", prog_name, "-m", mod_type], |
| 155 | + catch_exceptions=False |
| 156 | + ) |
| 157 | + assert res.exit_code == 0 |
| 158 | + assert (proj / "src" / "test_app" / "modules" / expected_file).exists() |
| 159 | + |
| 160 | + |
| 161 | +def test_new_invalid_name(runner, tmp_cwd): |
| 162 | + """Test 'new' command rejects invalid project names.""" |
| 163 | + # Empty name |
| 164 | + res = runner.invoke(main, ["new", ""], catch_exceptions=False) |
| 165 | + assert res.exit_code != 0 |
| 166 | + |
| 167 | + # Program name starting with digit should fail |
| 168 | + res = runner.invoke(main, ["new", "valid-proj", "-p", "1invalid"], catch_exceptions=False) |
| 169 | + assert res.exit_code != 0 |
| 170 | + assert "not a valid Python identifier" in res.output |
| 171 | + |
| 172 | + |
| 173 | +def test_generate_outside_project(runner, tmp_cwd): |
| 174 | + """Test 'generate' commands fail outside a DSPy project.""" |
| 175 | + # Try to generate without being in a project |
| 176 | + res = runner.invoke(main, ["g", "scaffold", "test"], catch_exceptions=False) |
| 177 | + assert res.exit_code != 0 |
| 178 | + assert "Not in a valid DSPy project" in res.output |
0 commit comments