Skip to content

Commit 65e12fb

Browse files
Add tests for specify doctor command
1 parent d5bd932 commit 65e12fb

1 file changed

Lines changed: 259 additions & 0 deletions

File tree

tests/test_doctor.py

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
"""
2+
Unit tests for the specify doctor command.
3+
4+
Tests cover:
5+
- Healthy project detection (all checks pass)
6+
- Missing project directories (.specify/, specs/, scripts/, templates/, memory/)
7+
- Missing constitution.md
8+
- AI agent folder detection and empty commands directory
9+
- Feature specification completeness (spec.md, plan.md, tasks.md)
10+
- Script existence validation (bash and powershell)
11+
- Extension config validation (extensions.yml, registry.json)
12+
- Git repository detection
13+
- Exit code 1 on errors, 0 on clean
14+
"""
15+
16+
import json
17+
import os
18+
import tempfile
19+
import shutil
20+
from pathlib import Path
21+
22+
import pytest
23+
from typer.testing import CliRunner
24+
25+
from specify_cli import app, AGENT_CONFIG
26+
27+
28+
runner = CliRunner()
29+
30+
31+
# ===== Fixtures =====
32+
33+
@pytest.fixture
34+
def temp_project():
35+
"""Create a temporary directory simulating a spec-kit project."""
36+
tmpdir = tempfile.mkdtemp()
37+
project = Path(tmpdir)
38+
yield project
39+
shutil.rmtree(tmpdir, ignore_errors=True)
40+
41+
42+
@pytest.fixture
43+
def healthy_project(temp_project):
44+
"""Create a fully healthy spec-kit project structure."""
45+
# Core directories
46+
(temp_project / ".specify").mkdir()
47+
(temp_project / "specs").mkdir()
48+
(temp_project / "scripts" / "bash").mkdir(parents=True)
49+
(temp_project / "scripts" / "powershell").mkdir(parents=True)
50+
(temp_project / "templates").mkdir()
51+
(temp_project / "memory").mkdir()
52+
53+
# Constitution
54+
(temp_project / "memory" / "constitution.md").write_text("# Constitution\n")
55+
56+
# Scripts
57+
expected_scripts = ["common", "check-prerequisites", "create-new-feature", "setup-plan", "update-agent-context"]
58+
for name in expected_scripts:
59+
(temp_project / "scripts" / "bash" / f"{name}.sh").write_text("#!/bin/bash\n")
60+
(temp_project / "scripts" / "powershell" / f"{name}.ps1").write_text("# PowerShell\n")
61+
62+
return temp_project
63+
64+
65+
# ===== Project Structure Tests =====
66+
67+
class TestDoctorProjectStructure:
68+
"""Tests for project directory checks."""
69+
70+
def test_healthy_project_no_errors(self, healthy_project):
71+
"""A fully set up project should report no errors."""
72+
os.chdir(healthy_project)
73+
result = runner.invoke(app, ["doctor"])
74+
assert result.exit_code == 0
75+
assert "error" not in result.output.lower() or "0 error" in result.output.lower()
76+
77+
def test_missing_specify_dir(self, temp_project):
78+
"""Missing .specify/ should be reported as an error."""
79+
os.chdir(temp_project)
80+
result = runner.invoke(app, ["doctor"])
81+
assert result.exit_code == 1
82+
assert "specify" in result.output.lower()
83+
84+
def test_missing_scripts_dir(self, healthy_project):
85+
"""Missing scripts/ should be reported as an error."""
86+
shutil.rmtree(healthy_project / "scripts")
87+
os.chdir(healthy_project)
88+
result = runner.invoke(app, ["doctor"])
89+
assert "scripts" in result.output.lower()
90+
91+
def test_missing_templates_dir(self, healthy_project):
92+
"""Missing templates/ should be reported as an error."""
93+
shutil.rmtree(healthy_project / "templates")
94+
os.chdir(healthy_project)
95+
result = runner.invoke(app, ["doctor"])
96+
assert "templates" in result.output.lower()
97+
98+
def test_missing_memory_dir(self, healthy_project):
99+
"""Missing memory/ should be reported as an error."""
100+
shutil.rmtree(healthy_project / "memory")
101+
os.chdir(healthy_project)
102+
result = runner.invoke(app, ["doctor"])
103+
assert result.exit_code == 1
104+
105+
def test_missing_constitution(self, healthy_project):
106+
"""Missing constitution.md should be reported as a warning."""
107+
(healthy_project / "memory" / "constitution.md").unlink()
108+
os.chdir(healthy_project)
109+
result = runner.invoke(app, ["doctor"])
110+
assert "constitution" in result.output.lower()
111+
112+
113+
# ===== AI Agent Tests =====
114+
115+
class TestDoctorAgentDetection:
116+
"""Tests for AI agent folder detection."""
117+
118+
def test_no_agent_detected(self, healthy_project):
119+
"""No agent folder should produce an info note."""
120+
os.chdir(healthy_project)
121+
result = runner.invoke(app, ["doctor"])
122+
assert "No AI agent" in result.output or "no ai agent" in result.output.lower()
123+
124+
def test_agent_with_commands(self, healthy_project):
125+
"""Agent folder with commands should report as healthy."""
126+
commands_dir = healthy_project / ".claude" / "commands"
127+
commands_dir.mkdir(parents=True)
128+
(commands_dir / "test.md").write_text("# Test command\n")
129+
os.chdir(healthy_project)
130+
result = runner.invoke(app, ["doctor"])
131+
assert "Claude Code" in result.output
132+
133+
def test_agent_folder_empty_commands(self, healthy_project):
134+
"""Agent folder without commands should report a warning."""
135+
(healthy_project / ".claude" / "commands").mkdir(parents=True)
136+
os.chdir(healthy_project)
137+
result = runner.invoke(app, ["doctor"])
138+
assert "warning" in result.output.lower() or "empty" in result.output.lower()
139+
140+
141+
# ===== Feature Specs Tests =====
142+
143+
class TestDoctorFeatureSpecs:
144+
"""Tests for feature specification checks."""
145+
146+
def test_no_specs_dir(self, healthy_project):
147+
"""No specs/ directory should skip feature checks gracefully."""
148+
shutil.rmtree(healthy_project / "specs")
149+
os.chdir(healthy_project)
150+
result = runner.invoke(app, ["doctor"])
151+
assert "not created yet" in result.output.lower() or "specs" in result.output.lower()
152+
153+
def test_feature_with_all_artifacts(self, healthy_project):
154+
"""Feature with spec, plan, and tasks should be fully green."""
155+
feature_dir = healthy_project / "specs" / "001-login"
156+
feature_dir.mkdir(parents=True)
157+
(feature_dir / "spec.md").write_text("# Spec\n")
158+
(feature_dir / "plan.md").write_text("# Plan\n")
159+
(feature_dir / "tasks.md").write_text("# Tasks\n")
160+
os.chdir(healthy_project)
161+
result = runner.invoke(app, ["doctor"])
162+
assert "001-login" in result.output
163+
assert "spec, plan, tasks all present" in result.output
164+
165+
def test_feature_missing_tasks(self, healthy_project):
166+
"""Feature missing tasks.md should report an info note."""
167+
feature_dir = healthy_project / "specs" / "002-signup"
168+
feature_dir.mkdir(parents=True)
169+
(feature_dir / "spec.md").write_text("# Spec\n")
170+
(feature_dir / "plan.md").write_text("# Plan\n")
171+
os.chdir(healthy_project)
172+
result = runner.invoke(app, ["doctor"])
173+
assert "002-signup" in result.output
174+
assert "tasks" in result.output.lower()
175+
176+
def test_feature_missing_spec(self, healthy_project):
177+
"""Feature missing spec.md should report an error."""
178+
feature_dir = healthy_project / "specs" / "003-broken"
179+
feature_dir.mkdir(parents=True)
180+
(feature_dir / "plan.md").write_text("# Plan\n")
181+
os.chdir(healthy_project)
182+
result = runner.invoke(app, ["doctor"])
183+
assert result.exit_code == 1
184+
185+
186+
# ===== Scripts Tests =====
187+
188+
class TestDoctorScripts:
189+
"""Tests for script health checks."""
190+
191+
def test_all_scripts_present(self, healthy_project):
192+
"""All scripts present should report ok."""
193+
os.chdir(healthy_project)
194+
result = runner.invoke(app, ["doctor"])
195+
assert result.exit_code == 0
196+
197+
def test_missing_bash_script(self, healthy_project):
198+
"""Missing a bash script should report an error."""
199+
(healthy_project / "scripts" / "bash" / "common.sh").unlink()
200+
os.chdir(healthy_project)
201+
result = runner.invoke(app, ["doctor"])
202+
assert result.exit_code == 1
203+
assert "common.sh" in result.output
204+
205+
def test_missing_powershell_script(self, healthy_project):
206+
"""Missing a PowerShell script should report an error."""
207+
(healthy_project / "scripts" / "powershell" / "setup-plan.ps1").unlink()
208+
os.chdir(healthy_project)
209+
result = runner.invoke(app, ["doctor"])
210+
assert result.exit_code == 1
211+
assert "setup-plan.ps1" in result.output
212+
213+
214+
# ===== Extensions Tests =====
215+
216+
class TestDoctorExtensions:
217+
"""Tests for extension health checks."""
218+
219+
def test_no_extensions(self, healthy_project):
220+
"""No extensions configured should skip gracefully."""
221+
os.chdir(healthy_project)
222+
result = runner.invoke(app, ["doctor"])
223+
assert "no extensions" in result.output.lower()
224+
225+
def test_valid_extensions_yml(self, healthy_project):
226+
"""Valid extensions.yml should report as healthy."""
227+
ext_yml = healthy_project / ".specify" / "extensions.yml"
228+
ext_yml.write_text("hooks:\n before_implement:\n - extension: test\n enabled: true\n")
229+
os.chdir(healthy_project)
230+
result = runner.invoke(app, ["doctor"])
231+
assert "valid YAML" in result.output or "hook" in result.output.lower()
232+
233+
def test_invalid_extensions_yml(self, healthy_project):
234+
"""Invalid YAML in extensions.yml should report a warning."""
235+
ext_yml = healthy_project / ".specify" / "extensions.yml"
236+
ext_yml.write_text(": : : invalid yaml [[[")
237+
os.chdir(healthy_project)
238+
result = runner.invoke(app, ["doctor"])
239+
assert "invalid" in result.output.lower() or "warning" in result.output.lower()
240+
241+
def test_valid_registry(self, healthy_project):
242+
"""Valid registry.json should report installed/enabled counts."""
243+
reg_dir = healthy_project / ".specify" / "extensions"
244+
reg_dir.mkdir(parents=True)
245+
registry = {"test-ext": {"enabled": True}, "other-ext": {"enabled": False}}
246+
(reg_dir / "registry.json").write_text(json.dumps(registry))
247+
os.chdir(healthy_project)
248+
result = runner.invoke(app, ["doctor"])
249+
assert "2 installed" in result.output
250+
assert "1 enabled" in result.output
251+
252+
def test_corrupt_registry(self, healthy_project):
253+
"""Corrupt registry.json should report an error."""
254+
reg_dir = healthy_project / ".specify" / "extensions"
255+
reg_dir.mkdir(parents=True)
256+
(reg_dir / "registry.json").write_text("not json at all {{{")
257+
os.chdir(healthy_project)
258+
result = runner.invoke(app, ["doctor"])
259+
assert result.exit_code == 1

0 commit comments

Comments
 (0)