mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
Agent-Logs-Url: https://github.com/github/spec-kit/sessions/ddeae546-8287-421f-bc5d-1636515bf99a Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com>
734 lines
29 KiB
Python
734 lines
29 KiB
Python
"""Tests for --integration flag on specify init (CLI-level)."""
|
|
|
|
import json
|
|
import os
|
|
|
|
import yaml
|
|
|
|
from tests.conftest import strip_ansi
|
|
|
|
|
|
def _normalize_cli_output(output: str) -> str:
|
|
output = strip_ansi(output)
|
|
output = " ".join(output.split())
|
|
return output.strip()
|
|
|
|
|
|
class TestInitIntegrationFlag:
|
|
def test_integration_and_ai_mutually_exclusive(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", str(tmp_path / "test-project"), "--ai", "claude", "--integration", "copilot",
|
|
])
|
|
assert result.exit_code != 0
|
|
assert "mutually exclusive" in result.output
|
|
|
|
def test_unknown_integration_rejected(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", str(tmp_path / "test-project"), "--integration", "nonexistent",
|
|
])
|
|
assert result.exit_code != 0
|
|
assert "Unknown integration" in result.output
|
|
|
|
def test_integration_copilot_creates_files(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
runner = CliRunner()
|
|
project = tmp_path / "int-test"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--integration", "copilot", "--script", "sh", "--no-git",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
|
assert (project / ".github" / "prompts" / "speckit.plan.prompt.md").exists()
|
|
assert (project / ".specify" / "scripts" / "bash" / "common.sh").exists()
|
|
|
|
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
|
|
assert data["integration"] == "copilot"
|
|
|
|
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
|
assert opts["integration"] == "copilot"
|
|
assert opts["context_file"] == ".github/copilot-instructions.md"
|
|
|
|
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
|
|
|
|
# Context section should be upserted into the copilot instructions file
|
|
ctx_file = project / ".github" / "copilot-instructions.md"
|
|
assert ctx_file.exists()
|
|
ctx_content = ctx_file.read_text(encoding="utf-8")
|
|
assert "<!-- SPECKIT START -->" in ctx_content
|
|
assert "<!-- SPECKIT END -->" in ctx_content
|
|
|
|
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
|
assert shared_manifest.exists()
|
|
|
|
def test_ai_copilot_auto_promotes(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "promote-test"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0
|
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
|
|
|
def test_ai_emits_deprecation_warning_with_integration_replacement(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "warn-ai"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--ai", "copilot", "--script", "sh", "--no-git",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Deprecation Warning" in normalized_output
|
|
assert "--ai" in normalized_output
|
|
assert "deprecated" in normalized_output
|
|
assert "no longer be available" in normalized_output
|
|
assert "0.10.0" in normalized_output
|
|
assert "--integration copilot" in normalized_output
|
|
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
|
assert (project / ".github" / "agents" / "speckit.plan.agent.md").exists()
|
|
|
|
def test_ai_generic_warning_suggests_integration_options_equivalent(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "warn-generic"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--ai", "generic", "--ai-commands-dir", ".myagent/commands",
|
|
"--script", "sh", "--no-git",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 0, result.output
|
|
assert "Deprecation Warning" in normalized_output
|
|
assert "--integration generic" in normalized_output
|
|
assert "--integration-options" in normalized_output
|
|
assert ".myagent/commands" in normalized_output
|
|
assert normalized_output.index("Deprecation Warning") < normalized_output.index("Next Steps")
|
|
assert (project / ".myagent" / "commands" / "speckit.plan.md").exists()
|
|
|
|
def test_ai_claude_here_preserves_preexisting_commands(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "claude-here-existing"
|
|
project.mkdir()
|
|
commands_dir = project / ".claude" / "skills"
|
|
commands_dir.mkdir(parents=True)
|
|
skill_dir = commands_dir / "speckit-specify"
|
|
skill_dir.mkdir(parents=True)
|
|
command_file = skill_dir / "SKILL.md"
|
|
command_file.write_text("# preexisting command\n", encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--force", "--ai", "claude", "--ai-skills", "--script", "sh", "--no-git", "--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert command_file.exists()
|
|
# init replaces skills (not additive); verify the file has valid skill content
|
|
assert command_file.exists()
|
|
assert "speckit-specify" in command_file.read_text(encoding="utf-8")
|
|
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
|
|
|
def test_shared_infra_skips_existing_files_without_force(self, tmp_path):
|
|
"""Pre-existing shared files are not overwritten without --force."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "skip-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
# Pre-create a shared script with custom content
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
# Pre-create a shared template with custom content
|
|
templates_dir = project / ".specify" / "templates"
|
|
templates_dir.mkdir(parents=True)
|
|
custom_template = "# user-modified spec-template\n"
|
|
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
# User's files should be preserved (not overwritten)
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
|
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") == custom_template
|
|
|
|
# Other shared files should still be installed
|
|
assert (scripts_dir / "setup-plan.sh").exists()
|
|
assert (templates_dir / "plan-template.md").exists()
|
|
|
|
def test_shared_infra_overwrites_existing_files_with_force(self, tmp_path):
|
|
"""Pre-existing shared files ARE overwritten when force=True."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "force-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
# Pre-create a shared script with custom content
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
# Pre-create a shared template with custom content
|
|
templates_dir = project / ".specify" / "templates"
|
|
templates_dir.mkdir(parents=True)
|
|
custom_template = "# user-modified spec-template\n"
|
|
(templates_dir / "spec-template.md").write_text(custom_template, encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=True)
|
|
|
|
# Files should be overwritten with bundled versions
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
|
assert (templates_dir / "spec-template.md").read_text(encoding="utf-8") != custom_template
|
|
|
|
# Other shared files should also be installed
|
|
assert (scripts_dir / "setup-plan.sh").exists()
|
|
assert (templates_dir / "plan-template.md").exists()
|
|
|
|
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
|
|
"""Console warning is displayed when files are skipped."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "warn-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=False)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "already exist and were not updated" in captured.out
|
|
assert "specify init --here --force" in captured.out
|
|
# Rich may wrap long lines; normalize whitespace for the second command
|
|
normalized = " ".join(captured.out.split())
|
|
assert "specify integration upgrade --force" in normalized
|
|
|
|
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
|
|
"""No skip warning when force=True (all files overwritten)."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "no-warn-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
(scripts_dir / "common.sh").write_text("# custom\n", encoding="utf-8")
|
|
|
|
_install_shared_infra(project, "sh", force=True)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "already exist and were not updated" not in captured.out
|
|
|
|
def test_init_here_force_overwrites_shared_infra(self, tmp_path):
|
|
"""E2E: specify init --here --force overwrites shared infra files."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "e2e-force"
|
|
project.mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--force",
|
|
"--integration", "copilot",
|
|
"--script", "sh",
|
|
"--no-git",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0
|
|
# --force should overwrite the custom file
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") != custom_content
|
|
|
|
def test_init_here_without_force_preserves_shared_infra(self, tmp_path):
|
|
"""E2E: specify init --here (no --force) preserves existing shared infra files."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "e2e-no-force"
|
|
project.mkdir()
|
|
|
|
scripts_dir = project / ".specify" / "scripts" / "bash"
|
|
scripts_dir.mkdir(parents=True)
|
|
custom_content = "# user-modified common.sh\n"
|
|
(scripts_dir / "common.sh").write_text(custom_content, encoding="utf-8")
|
|
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here",
|
|
"--integration", "copilot",
|
|
"--script", "sh",
|
|
"--no-git",
|
|
], input="y\n", catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0
|
|
# Without --force, custom file should be preserved
|
|
assert (scripts_dir / "common.sh").read_text(encoding="utf-8") == custom_content
|
|
# Warning about skipped files should appear
|
|
assert "not updated" in result.output
|
|
|
|
|
|
class TestForceExistingDirectory:
|
|
"""Tests for --force merging into an existing named directory."""
|
|
|
|
def test_force_merges_into_existing_dir(self, tmp_path):
|
|
"""specify init <dir> --force succeeds when the directory already exists."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
target = tmp_path / "existing-proj"
|
|
target.mkdir()
|
|
# Place a pre-existing file to verify it survives the merge
|
|
marker = target / "user-file.txt"
|
|
marker.write_text("keep me", encoding="utf-8")
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", str(target), "--integration", "copilot", "--force",
|
|
"--no-git", "--script", "sh",
|
|
], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 0, f"init --force failed: {result.output}"
|
|
|
|
# Pre-existing file should survive
|
|
assert marker.read_text(encoding="utf-8") == "keep me"
|
|
|
|
# Spec Kit files should be installed
|
|
assert (target / ".specify" / "init-options.json").exists()
|
|
assert (target / ".specify" / "templates" / "spec-template.md").exists()
|
|
|
|
def test_without_force_errors_on_existing_dir(self, tmp_path):
|
|
"""specify init <dir> without --force errors when directory exists."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
target = tmp_path / "existing-proj"
|
|
target.mkdir()
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", str(target), "--integration", "copilot",
|
|
"--no-git", "--script", "sh",
|
|
], catch_exceptions=False)
|
|
|
|
assert result.exit_code == 1
|
|
assert "already exists" in _normalize_cli_output(result.output)
|
|
|
|
|
|
class TestGitExtensionAutoInstall:
|
|
"""Tests for auto-installation of the git extension during specify init."""
|
|
|
|
def test_git_extension_auto_installed(self, tmp_path):
|
|
"""Without --no-git, the git extension is installed during init."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "git-auto"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
# Check that the tracker didn't report a git error
|
|
assert "install failed" not in result.output, f"git extension install failed: {result.output}"
|
|
|
|
# Git extension files should be installed
|
|
ext_dir = project / ".specify" / "extensions" / "git"
|
|
assert ext_dir.exists(), "git extension directory not installed"
|
|
assert (ext_dir / "extension.yml").exists()
|
|
assert (ext_dir / "scripts" / "bash" / "create-new-feature.sh").exists()
|
|
assert (ext_dir / "scripts" / "bash" / "initialize-repo.sh").exists()
|
|
|
|
# Hooks should be registered
|
|
extensions_yml = project / ".specify" / "extensions.yml"
|
|
assert extensions_yml.exists(), "extensions.yml not created"
|
|
hooks_data = yaml.safe_load(extensions_yml.read_text(encoding="utf-8"))
|
|
assert "hooks" in hooks_data
|
|
assert "before_specify" in hooks_data["hooks"]
|
|
assert "before_constitution" in hooks_data["hooks"]
|
|
|
|
def test_no_git_skips_extension(self, tmp_path):
|
|
"""With --no-git, the git extension is NOT installed."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "no-git"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
|
"--no-git", "--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
# Git extension should NOT be installed
|
|
ext_dir = project / ".specify" / "extensions" / "git"
|
|
assert not ext_dir.exists(), "git extension should not be installed with --no-git"
|
|
|
|
def test_no_git_emits_deprecation_warning(self, tmp_path):
|
|
"""Using --no-git emits a visible deprecation warning."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "no-git-warn"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
|
"--no-git", "--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
normalized_output = _normalize_cli_output(result.output)
|
|
assert result.exit_code == 0, result.output
|
|
assert "--no-git" in normalized_output
|
|
assert "deprecated" in normalized_output
|
|
assert "0.10.0" in normalized_output
|
|
assert "specify extension" in normalized_output
|
|
assert "will be removed" in normalized_output
|
|
assert "git extension will no longer be enabled by default" in normalized_output
|
|
|
|
def test_git_extension_commands_registered(self, tmp_path):
|
|
"""Git extension commands are registered with the agent during init."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "git-cmds"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, [
|
|
"init", "--here", "--ai", "claude", "--script", "sh",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
# Git extension commands should be registered with the agent
|
|
claude_skills = project / ".claude" / "skills"
|
|
assert claude_skills.exists(), "Claude skills directory was not created"
|
|
git_skills = [f for f in claude_skills.iterdir() if f.name.startswith("speckit-git-")]
|
|
assert len(git_skills) > 0, "no git extension commands registered"
|
|
|
|
|
|
class TestSharedInfraCommandRefs:
|
|
"""Verify _install_shared_infra resolves __SPECKIT_COMMAND_*__ in page templates."""
|
|
|
|
def test_dot_separator_in_page_templates(self, tmp_path):
|
|
"""Markdown agents get /speckit.<name> in page templates."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "dot-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
_install_shared_infra(project, "sh", invoke_separator=".")
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
assert plan.exists()
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
|
assert "/speckit.plan" in content
|
|
|
|
checklist = project / ".specify" / "templates" / "checklist-template.md"
|
|
content = checklist.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
assert "/speckit.checklist" in content
|
|
|
|
def test_hyphen_separator_in_page_templates(self, tmp_path):
|
|
"""Skills agents get /speckit-<name> in page templates."""
|
|
from specify_cli import _install_shared_infra
|
|
|
|
project = tmp_path / "hyphen-test"
|
|
project.mkdir()
|
|
(project / ".specify").mkdir()
|
|
|
|
_install_shared_infra(project, "sh", invoke_separator="-")
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
assert plan.exists()
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content, "unresolved placeholder in plan-template.md"
|
|
assert "/speckit-plan" in content
|
|
assert "/speckit.plan" not in content, "dot-notation leaked into skills page template"
|
|
|
|
tasks = project / ".specify" / "templates" / "tasks-template.md"
|
|
content = tasks.read_text(encoding="utf-8")
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
assert "/speckit-tasks" in content
|
|
|
|
def test_full_init_claude_resolves_page_templates(self, tmp_path):
|
|
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
project = tmp_path / "init-claude"
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"init", str(project),
|
|
"--integration", "claude",
|
|
"--script", "sh",
|
|
"--no-git",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in content, "Claude (skills) should use /speckit-plan"
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
|
|
def test_full_init_copilot_resolves_page_templates(self, tmp_path):
|
|
"""Full CLI init with Copilot (markdown agent) produces dot refs in page templates."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
project = tmp_path / "init-copilot"
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"init", str(project),
|
|
"--integration", "copilot",
|
|
"--script", "sh",
|
|
"--no-git",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "/speckit.plan" in content, "Copilot (markdown) should use /speckit.plan"
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
|
|
def test_full_init_copilot_skills_resolves_page_templates(self, tmp_path):
|
|
"""Full CLI init with Copilot --skills produces hyphen refs in page templates."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
runner = CliRunner()
|
|
project = tmp_path / "init-copilot-skills"
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(tmp_path)
|
|
result = runner.invoke(app, [
|
|
"init", str(project),
|
|
"--integration", "copilot",
|
|
"--integration-options", "--skills",
|
|
"--script", "sh",
|
|
"--no-git",
|
|
"--ignore-agent-tools",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
|
|
plan = project / ".specify" / "templates" / "plan-template.md"
|
|
content = plan.read_text(encoding="utf-8")
|
|
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
|
|
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
|
assert "__SPECKIT_COMMAND_" not in content
|
|
|
|
|
|
class TestExtensionFlag:
|
|
"""Tests for the --extension flag on specify init."""
|
|
|
|
def _run_init(self, tmp_path, args, project_name="ext-test"):
|
|
from unittest.mock import patch
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / project_name
|
|
project.mkdir(exist_ok=True)
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
runner = CliRunner()
|
|
# Patch get_speckit_version to return a stable (non-dev) version so that
|
|
# the extension compatibility check (SpecifierSet(">=0.2.0")) passes.
|
|
with patch("specify_cli.get_speckit_version", return_value="0.8.2"):
|
|
result = runner.invoke(app, [
|
|
"init", "--here",
|
|
"--integration", "copilot",
|
|
"--script", "sh",
|
|
"--no-git",
|
|
"--ignore-agent-tools",
|
|
] + args, catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
return project, result
|
|
|
|
def test_bundled_extension_installed(self, tmp_path):
|
|
"""--extension git installs the bundled git extension."""
|
|
project, result = self._run_init(tmp_path, ["--extension", "git"], project_name="ext-bundled")
|
|
|
|
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
|
|
|
ext_dir = project / ".specify" / "extensions" / "git"
|
|
assert ext_dir.exists(), "git extension directory not found"
|
|
assert (ext_dir / "extension.yml").exists(), "extension.yml not found"
|
|
|
|
# Tracker should show extension step as done
|
|
normalized = _normalize_cli_output(result.output)
|
|
assert "Install extension: git" in normalized
|
|
|
|
def test_multiple_extensions_installed(self, tmp_path):
|
|
"""--extension can be specified multiple times."""
|
|
project, result = self._run_init(
|
|
tmp_path,
|
|
["--extension", "git", "--extension", "selftest"],
|
|
project_name="ext-multi",
|
|
)
|
|
|
|
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
|
|
|
ext_dir_git = project / ".specify" / "extensions" / "git"
|
|
ext_dir_selftest = project / ".specify" / "extensions" / "selftest"
|
|
assert ext_dir_git.exists(), "git extension not installed"
|
|
assert ext_dir_selftest.exists(), "selftest extension not installed"
|
|
|
|
def test_local_path_extension_installed(self, tmp_path):
|
|
"""--extension /abs/path installs from a local absolute directory path."""
|
|
from specify_cli import _locate_bundled_extension
|
|
|
|
# Use the bundled git extension directory as our "local" extension source
|
|
bundled_git = _locate_bundled_extension("git")
|
|
assert bundled_git is not None, "bundled git extension not found; cannot run test"
|
|
|
|
# Pass the absolute path directly (starts with "/")
|
|
project, result = self._run_init(
|
|
tmp_path,
|
|
["--extension", str(bundled_git)],
|
|
project_name="ext-local",
|
|
)
|
|
|
|
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
|
|
|
ext_dir = project / ".specify" / "extensions" / "git"
|
|
assert ext_dir.exists(), "extension from local path not installed"
|
|
|
|
def test_unknown_extension_shows_error_in_tracker(self, tmp_path):
|
|
"""An unknown extension name records a tracker error but does not abort init."""
|
|
project, result = self._run_init(
|
|
tmp_path,
|
|
["--extension", "nonexistent-xyz-ext"],
|
|
project_name="ext-unknown",
|
|
)
|
|
|
|
assert result.exit_code == 0, "init should not abort on unknown extension"
|
|
normalized = _normalize_cli_output(result.output)
|
|
assert "failed" in normalized.lower(), "expected 'failed' for unknown extension"
|
|
|
|
def test_extension_flag_works_with_preset(self, tmp_path):
|
|
"""--extension and --preset can be combined."""
|
|
project, result = self._run_init(
|
|
tmp_path,
|
|
["--extension", "git", "--preset", "lean"],
|
|
project_name="ext-preset",
|
|
)
|
|
|
|
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
|
|
|
ext_dir = project / ".specify" / "extensions" / "git"
|
|
assert ext_dir.exists(), "git extension not installed alongside preset"
|