Files
spec-kit/tests/test_speckit.py
T
2026-08-19 11:03:16 +02:00

611 lines
21 KiB
Python

"""Tests for speckit — local rebuild of the spec-kit idea (TDD)."""
import subprocess
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SPECKIT = REPO / "speckit.py"
SUBCOMMANDS = [
"init", "specify", "plan", "tasks", "switch", "status", "check",
"clarify", "constitution", "checklist", "converge",
]
def run_cli(*args, cwd=None):
return subprocess.run(
[sys.executable, str(SPECKIT), *args],
capture_output=True, text=True, cwd=cwd,
)
# --- T1: skeleton ---
def test_help_exits_zero_and_lists_all_subcommands():
result = run_cli("--help")
assert result.returncode == 0
for sub in SUBCOMMANDS:
assert sub in result.stdout
# --- T2: templates ---
# Heading strings are hard-coded literals here on purpose: they pin the
# REQUIRED_SECTIONS constant from outside (anti-tautology).
TEMPLATES = REPO / "templates"
def test_spec_template_headings_and_tokens():
text = (TEMPLATES / "spec-template.md").read_text()
for heading in ["## User Stories", "## Requirements", "## Success Criteria", "## Assumptions"]:
assert heading in text
for token in ["{{DESCRIPTION}}", "{{DATE}}", "{{FEATURE}}"]:
assert token in text
def test_plan_template_headings_and_tokens():
text = (TEMPLATES / "plan-template.md").read_text()
for heading in ["## Technical Context", "## Structure", "## Decisions"]:
assert heading in text
for token in ["{{DATE}}", "{{FEATURE}}"]:
assert token in text
def test_tasks_template_heading_and_exactly_six_open_boxes():
text = (TEMPLATES / "tasks-template.md").read_text()
assert "## Phases" in text
open_boxes = [l for l in text.splitlines() if l.lstrip().startswith(("- [ ]", "* [ ]"))]
assert len(open_boxes) == 6
def test_constitution_template_tokens():
text = (TEMPLATES / "constitution-template.md").read_text()
for token in ["{{PROJECT}}", "{{DATE}}"]:
assert token in text
# --- T3: init ---
import json
def test_init_creates_exact_scaffold(tmp_path):
result = run_cli("init", "demo", cwd=tmp_path)
assert result.returncode == 0
proj = tmp_path / "demo"
assert (proj / ".speckit" / "state.json").is_file()
for name in ["spec-template.md", "plan-template.md", "tasks-template.md", "constitution-template.md"]:
assert (proj / ".speckit" / "templates" / name).is_file()
assert (proj / "memory" / "constitution.md").is_file()
assert (proj / "specs").is_dir()
assert list((proj / "specs").iterdir()) == []
state = json.loads((proj / ".speckit" / "state.json").read_text())
assert state == {"project": "demo", "current_feature": None, "features": []}
constitution = (proj / "memory" / "constitution.md").read_text()
assert "{{" not in constitution
assert "demo" in constitution
def test_init_refuses_existing_project(tmp_path):
assert run_cli("init", "demo", cwd=tmp_path).returncode == 0
result = run_cli("init", "demo", cwd=tmp_path)
assert result.returncode == 1
assert ".speckit" in result.stderr
# --- T4: specify + switch + root discovery ---
def make_project(tmp_path, name="demo"):
assert run_cli("init", name, cwd=tmp_path).returncode == 0
return tmp_path / name
def read_state(proj):
return json.loads((proj / ".speckit" / "state.json").read_text())
def test_specify_creates_first_feature_001(tmp_path):
proj = make_project(tmp_path)
result = run_cli("specify", "recipe box organizer", cwd=proj)
assert result.returncode == 0
spec = proj / "specs" / "001-recipe-box-organizer" / "spec.md"
assert spec.is_file()
text = spec.read_text()
assert "recipe box organizer" in text
assert "{{" not in text
state = read_state(proj)
assert state["current_feature"] == "001-recipe-box-organizer"
assert state["features"] == ["001-recipe-box-organizer"]
def test_specify_increments_number_and_switches_current(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
result = run_cli("specify", "meal shopping list", cwd=proj)
assert result.returncode == 0
assert (proj / "specs" / "002-meal-shopping-list" / "spec.md").is_file()
assert read_state(proj)["current_feature"] == "002-meal-shopping-list"
def test_slug_keeps_first_four_words(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "A Really long, feature name here!", cwd=proj)
assert (proj / "specs" / "001-a-really-long-feature").is_dir()
def test_switch_by_number_and_slug(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
run_cli("specify", "meal shopping list", cwd=proj)
assert run_cli("switch", "001", cwd=proj).returncode == 0
assert read_state(proj)["current_feature"] == "001-recipe-box-organizer"
assert run_cli("switch", "meal-shopping-list", cwd=proj).returncode == 0
assert read_state(proj)["current_feature"] == "002-meal-shopping-list"
def test_switch_unknown_feature_lists_known(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
result = run_cli("switch", "nope", cwd=proj)
assert result.returncode == 1
assert "001-recipe-box-organizer" in result.stderr
def test_command_outside_project_exits_2_and_writes_nothing(tmp_path):
before = sorted(p.name for p in tmp_path.iterdir())
result = run_cli("specify", "anything", cwd=tmp_path)
assert result.returncode == 2
assert "not inside a speckit project" in result.stderr
assert sorted(p.name for p in tmp_path.iterdir()) == before
def test_root_discovery_walks_up_from_nested_subdir(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
nested = proj / "specs" / "001-recipe-box-organizer"
result = run_cli("specify", "meal shopping list", cwd=nested)
assert result.returncode == 0
assert (proj / "specs" / "002-meal-shopping-list" / "spec.md").is_file()
assert not (nested / "specs").exists()
# --- T5: plan/tasks phase gates ---
def test_plan_without_feature_exits_1(tmp_path):
proj = make_project(tmp_path)
result = run_cli("plan", cwd=proj)
assert result.returncode == 1
assert "no feature selected" in result.stderr
def test_plan_seeds_plan_md(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
result = run_cli("plan", cwd=proj)
assert result.returncode == 0
plan = proj / "specs" / "001-recipe-box-organizer" / "plan.md"
assert plan.is_file()
text = plan.read_text()
assert "001-recipe-box-organizer" in text
assert "{{" not in text
def test_plan_never_overwrites(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
run_cli("plan", cwd=proj)
plan = proj / "specs" / "001-recipe-box-organizer" / "plan.md"
plan.write_text("edited by hand")
result = run_cli("plan", cwd=proj)
assert result.returncode == 0
assert "exists" in result.stdout
assert plan.read_text() == "edited by hand"
def test_plan_gate_requires_spec(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
(proj / "specs" / "001-recipe-box-organizer" / "spec.md").unlink()
result = run_cli("plan", cwd=proj)
assert result.returncode == 1
assert "spec.md" in result.stderr
def test_tasks_gate_requires_plan(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
result = run_cli("tasks", cwd=proj)
assert result.returncode == 1
assert "plan.md" in result.stderr
def test_tasks_seeds_tasks_md_after_plan(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
run_cli("plan", cwd=proj)
result = run_cli("tasks", cwd=proj)
assert result.returncode == 0
tasks = proj / "specs" / "001-recipe-box-organizer" / "tasks.md"
assert tasks.is_file()
assert "{{" not in tasks.read_text()
# --- T6: status ---
# Fixture counts below are written BY HAND (3 done, 4 open), never derived
# from the code's own regexes.
TASKS_FIXTURE = """# Tasks
## Phases
- [x] plain done
- [X] indented uppercase done
* [x] star bullet done
- [ ] plain open
- [ ] indented open
* [ ] star bullet open
- [ ] fourth open
"""
def test_status_on_empty_project(tmp_path):
proj = make_project(tmp_path)
result = run_cli("status", cwd=proj)
assert result.returncode == 0
assert "no features" in result.stdout
def test_status_shows_phases_current_and_counts(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
run_cli("plan", cwd=proj)
run_cli("tasks", cwd=proj)
run_cli("specify", "meal shopping list", cwd=proj)
run_cli("switch", "001", cwd=proj)
(proj / "specs" / "001-recipe-box-organizer" / "tasks.md").write_text(TASKS_FIXTURE)
result = run_cli("status", cwd=proj)
assert result.returncode == 0
out = result.stdout
assert "* 001-recipe-box-organizer" in out # current marker
assert "3 done / 4 open" in out # hand-counted fixture
lines_002 = [l for l in out.splitlines() if "002-meal-shopping-list" in l]
assert len(lines_002) == 1
assert not lines_002[0].startswith("* ")
assert "spec" in lines_002[0]
# --- T7: check ---
def full_feature(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
run_cli("plan", cwd=proj)
run_cli("tasks", cwd=proj)
return proj, proj / "specs" / "001-recipe-box-organizer"
def test_check_clean_full_feature_exits_0(tmp_path):
proj, _ = full_feature(tmp_path)
result = run_cli("check", cwd=proj)
assert result.returncode == 0
assert "NEEDS CLARIFICATION: 0" in result.stdout
def test_check_without_feature_exits_1(tmp_path):
proj = make_project(tmp_path)
result = run_cli("check", cwd=proj)
assert result.returncode == 1
assert "no feature selected" in result.stderr
def test_check_spec_only_feature_is_clean(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
result = run_cli("check", cwd=proj)
assert result.returncode == 0
assert result.stdout.count("phase not started") == 2 # plan + tasks
def test_check_exactly_three_markers_passes(tmp_path):
proj, feature = full_feature(tmp_path)
spec = feature / "spec.md"
spec.write_text(
spec.read_text()
+ "\n- R2: sorting order [NEEDS CLARIFICATION: by date or name?]"
+ "\n- R3: limits [NEEDS CLARIFICATION: max photos?]"
+ "\n- R4: sharing [NEEDS CLARIFICATION: export format?]\n"
)
result = run_cli("check", cwd=proj)
assert result.returncode == 0
assert "NEEDS CLARIFICATION: 3" in result.stdout
def test_check_flags_missing_heading_and_four_markers(tmp_path):
proj, feature = full_feature(tmp_path)
spec = feature / "spec.md"
text = spec.read_text().replace("## Requirements", "## Stuff")
text += "".join(
f"\n- R{i}: thing [NEEDS CLARIFICATION: q{i}?]" for i in range(4)
)
spec.write_text(text)
result = run_cli("check", cwd=proj)
assert result.returncode == 1
assert "## Requirements" in result.stdout
assert "NEEDS CLARIFICATION: 4" in result.stdout
def test_check_missing_spec_is_finding(tmp_path):
proj, feature = full_feature(tmp_path)
(feature / "spec.md").unlink()
result = run_cli("check", cwd=proj)
assert result.returncode == 1
assert "spec.md" in result.stdout
def test_check_missing_constitution_is_finding(tmp_path):
proj, _ = full_feature(tmp_path)
(proj / "memory" / "constitution.md").unlink()
result = run_cli("check", cwd=proj)
assert result.returncode == 1
assert "constitution" in result.stdout
# --- T8: agent command prompts ---
import re as _re
PROMPT_FILES = {
"speckit.specify.md": "spec.md",
"speckit.plan.md": "plan.md",
"speckit.tasks.md": "tasks.md",
"speckit.implement.md": "tasks.md",
}
PROMPT_GATES = {
"speckit.plan.md": "spec.md", # plan gated on spec
"speckit.tasks.md": "plan.md", # tasks gated on plan
"speckit.implement.md": "tasks.md", # implement gated on tasks existing
}
def test_init_with_agent_writes_four_prompts_naming_artifact_and_gate(tmp_path):
assert run_cli("init", "demo", "--agent", "claude", cwd=tmp_path).returncode == 0
commands = tmp_path / "demo" / ".claude" / "commands"
for name, artifact in PROMPT_FILES.items():
text = (commands / name).read_text()
assert artifact in text
for name, gate in PROMPT_GATES.items():
assert gate in (commands / name).read_text()
def test_init_without_agent_writes_no_claude_dir(tmp_path):
assert run_cli("init", "demo", cwd=tmp_path).returncode == 0
assert not (tmp_path / "demo" / ".claude").exists()
# --- specify from file + --name override (bug: `specify prompt.txt` made 001-prompt-txt) ---
def test_specify_reads_description_from_existing_file(tmp_path):
proj = make_project(tmp_path)
(proj / "prompt.txt").write_text("superhero visual room overview\n\nContext: show all installed agents.\n")
result = run_cli("specify", "prompt.txt", cwd=proj)
assert result.returncode == 0
assert "prompt.txt" in result.stdout # says it read the file
feature_dir = proj / "specs" / "001-superhero-visual-room-overview"
assert feature_dir.is_dir() # slug from content, not filename
spec_text = (feature_dir / "spec.md").read_text()
assert "show all installed agents" in spec_text
def test_specify_name_overrides_slug(tmp_path):
proj = make_project(tmp_path)
(proj / "prompt.txt").write_text("title: Fancy Thing category: builds\nlots of prose here")
result = run_cli("specify", "prompt.txt", "--name", "visual room", cwd=proj)
assert result.returncode == 0
assert (proj / "specs" / "001-visual-room").is_dir()
assert "lots of prose here" in (proj / "specs" / "001-visual-room" / "spec.md").read_text()
# --- O5: timestamp numbering ---
def test_specify_timestamp_mode_prefixes_datetime(tmp_path):
proj = make_project(tmp_path)
result = run_cli("specify", "--timestamp", "recipe box organizer", cwd=proj)
assert result.returncode == 0
dirs = [p.name for p in (proj / "specs").iterdir()]
assert len(dirs) == 1
assert _re.fullmatch(r"\d{8}-\d{6}-recipe-box-organizer", dirs[0])
assert read_state(proj)["current_feature"] == dirs[0]
def test_switch_by_slug_works_for_timestamp_feature(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "--timestamp", "recipe box organizer", cwd=proj)
run_cli("specify", "meal shopping list", cwd=proj)
assert run_cli("switch", "recipe-box-organizer", cwd=proj).returncode == 0
assert read_state(proj)["current_feature"].endswith("-recipe-box-organizer")
# --- O4: checklists ---
def test_checklist_seeds_file_with_open_items(tmp_path):
proj, feature = full_feature(tmp_path)
result = run_cli("checklist", "requirements", cwd=proj)
assert result.returncode == 0
checklist = feature / "checklists" / "requirements.md"
assert checklist.is_file()
text = checklist.read_text()
assert "{{" not in text
assert RE_OPEN_BOX_LINES(text)
def RE_OPEN_BOX_LINES(text):
return [l for l in text.splitlines() if l.lstrip().startswith(("- [ ]", "* [ ]"))]
def test_checklist_without_feature_exits_1(tmp_path):
proj = make_project(tmp_path)
result = run_cli("checklist", "requirements", cwd=proj)
assert result.returncode == 1
assert "no feature selected" in result.stderr
def test_checklist_never_overwrites(tmp_path):
proj, feature = full_feature(tmp_path)
run_cli("checklist", "requirements", cwd=proj)
target = feature / "checklists" / "requirements.md"
target.write_text("edited by hand")
result = run_cli("checklist", "requirements", cwd=proj)
assert result.returncode == 0
assert "exists" in result.stdout
assert target.read_text() == "edited by hand"
def test_check_warns_on_unchecked_checklist_items_but_stays_clean(tmp_path):
proj, feature = full_feature(tmp_path)
run_cli("checklist", "requirements", cwd=proj)
n_open = len(RE_OPEN_BOX_LINES((feature / "checklists" / "requirements.md").read_text()))
result = run_cli("check", cwd=proj)
assert result.returncode == 0
assert f"checklist requirements: {n_open} unchecked" in result.stdout
# --- O6: converge ---
def test_converge_reports_converged_and_leaves_tasks_untouched(tmp_path):
proj, feature = full_feature(tmp_path)
before = (feature / "tasks.md").read_bytes()
result = run_cli("converge", cwd=proj)
assert result.returncode == 0
assert "converged" in result.stdout
assert (feature / "tasks.md").read_bytes() == before
def test_converge_appends_tasks_for_uncovered_stories(tmp_path):
proj, feature = full_feature(tmp_path)
spec = feature / "spec.md"
spec.write_text(spec.read_text() + "\n### Story 2 — (P2)\n\nAs a cook, I want tags.\n")
before = (feature / "tasks.md").read_text()
result = run_cli("converge", cwd=proj)
assert result.returncode == 0
after = (feature / "tasks.md").read_text()
assert after.startswith(before) # append-only
appended = after[len(before):]
assert "Story 2" in appended
assert "### Convergence" in appended
assert RE_OPEN_BOX_LINES(appended)
# idempotent: second run finds nothing new
result2 = run_cli("converge", cwd=proj)
assert result2.returncode == 0
assert "converged" in result2.stdout
assert (feature / "tasks.md").read_text() == after
def test_converge_requires_tasks_md(tmp_path):
proj = make_project(tmp_path)
run_cli("specify", "recipe box organizer", cwd=proj)
result = run_cli("converge", cwd=proj)
assert result.returncode == 1
assert "tasks.md" in result.stderr
# --- O1: ANSI colors ---
def test_color_always_emits_ansi_codes(tmp_path):
proj, _ = full_feature(tmp_path)
result = run_cli("--color", "always", "status", cwd=proj)
assert result.returncode == 0
assert "\x1b[" in result.stdout
def test_no_color_by_default_when_not_a_tty(tmp_path):
proj, _ = full_feature(tmp_path)
result = run_cli("status", cwd=proj)
assert result.returncode == 0
assert "\x1b[" not in result.stdout
def test_color_always_paints_findings_red(tmp_path):
proj, feature = full_feature(tmp_path)
(feature / "spec.md").unlink()
result = run_cli("--color", "always", "check", cwd=proj)
assert result.returncode == 1
assert "\x1b[31m" in result.stdout
# --- O3: constitution ---
def test_constitution_shows_current_version(tmp_path):
proj = make_project(tmp_path)
result = run_cli("constitution", cwd=proj)
assert result.returncode == 0
assert "1.0.0" in result.stdout
def test_constitution_amend_bumps_minor_and_records_entry(tmp_path):
proj = make_project(tmp_path)
result = run_cli("constitution", "--amend", "Article V — no network calls at runtime", cwd=proj)
assert result.returncode == 0
text = (proj / "memory" / "constitution.md").read_text()
assert "version 1.1.0" in text
assert "no network calls at runtime" in text
assert run_cli("constitution", cwd=proj).stdout.strip().endswith("1.1.0")
def test_constitution_amend_major_bump(tmp_path):
proj = make_project(tmp_path)
run_cli("constitution", "--amend", "first change", cwd=proj) # 1.1.0
result = run_cli("constitution", "--amend", "breaking change", "--major", cwd=proj)
assert result.returncode == 0
assert "2.0.0" in (proj / "memory" / "constitution.md").read_text()
def test_constitution_missing_file_exits_1(tmp_path):
proj = make_project(tmp_path)
(proj / "memory" / "constitution.md").unlink()
result = run_cli("constitution", cwd=proj)
assert result.returncode == 1
assert "constitution" in result.stderr
# --- O2: clarify ---
def test_clarify_lists_markers_with_file_and_line(tmp_path):
proj, feature = full_feature(tmp_path)
spec = feature / "spec.md"
lines = spec.read_text().splitlines()
lines.append("- R2: order [NEEDS CLARIFICATION: by date or name?]")
spec.write_text("\n".join(lines) + "\n")
marker_line = len(lines) # appended last, 1-based
result = run_cli("clarify", cwd=proj)
assert result.returncode == 0
assert f"spec.md:{marker_line}:" in result.stdout
assert "by date or name?" in result.stdout
def test_clarify_reports_when_clean(tmp_path):
proj, _ = full_feature(tmp_path)
result = run_cli("clarify", cwd=proj)
assert result.returncode == 0
assert "no clarification markers" in result.stdout
def test_clarify_without_feature_exits_1(tmp_path):
proj = make_project(tmp_path)
result = run_cli("clarify", cwd=proj)
assert result.returncode == 1
assert "no feature selected" in result.stderr
def test_prompt_verbs_are_registered_subcommands(tmp_path):
assert run_cli("init", "demo", "--agent", "claude", cwd=tmp_path).returncode == 0
commands = tmp_path / "demo" / ".claude" / "commands"
verbs = set()
for prompt in commands.glob("*.md"):
verbs.update(_re.findall(r"speckit\.py (\w+)", prompt.read_text()))
assert verbs # prompts must actually reference the CLI
assert verbs <= set(SUBCOMMANDS)