mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
312 lines
13 KiB
Python
312 lines
13 KiB
Python
"""Tests for GenericIntegration."""
|
|||
|
|
|
||
|
|
import os
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from specify_cli.integrations import get_integration
|
||
|
|
from specify_cli.integrations.base import MarkdownIntegration
|
||
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
||
|
|
|
||
|
|
|
||
|
|
class TestGenericIntegration:
|
||
|
|
"""Tests for GenericIntegration — requires --commands-dir option."""
|
||
|
|
|
||
|
|
# -- Registration -----------------------------------------------------
|
||
|
|
|
||
|
|
def test_registered(self):
|
||
|
|
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||
|
|
assert "generic" in INTEGRATION_REGISTRY
|
||
|
|
|
||
|
|
def test_is_markdown_integration(self):
|
||
|
|
assert isinstance(get_integration("generic"), MarkdownIntegration)
|
||
|
|
|
||
|
|
# -- Config -----------------------------------------------------------
|
||
|
|
|
||
|
|
def test_config_folder_is_none(self):
|
||
|
|
i = get_integration("generic")
|
||
|
|
assert i.config["folder"] is None
|
||
|
|
|
||
|
|
def test_config_requires_cli_false(self):
|
||
|
|
i = get_integration("generic")
|
||
|
|
assert i.config["requires_cli"] is False
|
||
|
|
|
||
|
|
def test_context_file_is_none(self):
|
||
|
|
i = get_integration("generic")
|
||
|
|
assert i.context_file is None
|
||
|
|
|
||
|
|
# -- Options ----------------------------------------------------------
|
||
|
|
|
||
|
|
def test_options_include_commands_dir(self):
|
||
|
|
i = get_integration("generic")
|
||
|
|
opts = i.options()
|
||
|
|
assert len(opts) == 1
|
||
|
|
assert opts[0].name == "--commands-dir"
|
||
|
|
assert opts[0].required is True
|
||
|
|
assert opts[0].is_flag is False
|
||
|
|
|
||
|
|
# -- Setup / teardown -------------------------------------------------
|
||
|
|
|
||
|
|
def test_setup_requires_commands_dir(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||
|
|
i.setup(tmp_path, m, parsed_options={})
|
||
|
|
|
||
|
|
def test_setup_requires_nonempty_commands_dir(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||
|
|
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
|
||
|
|
|
||
|
|
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
created = i.setup(
|
||
|
|
tmp_path, m,
|
||
|
|
parsed_options={"commands_dir": ".myagent/commands"},
|
||
|
|
)
|
||
|
|
expected_dir = tmp_path / ".myagent" / "commands"
|
||
|
|
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
|
||
|
|
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||
|
|
assert len(cmd_files) > 0, "No command files were created"
|
||
|
|
for f in cmd_files:
|
||
|
|
assert f.resolve().parent == expected_dir.resolve(), (
|
||
|
|
f"{f} is not under {expected_dir}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_setup_creates_md_files(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
created = i.setup(
|
||
|
|
tmp_path, m,
|
||
|
|
parsed_options={"commands_dir": ".custom/cmds"},
|
||
|
|
)
|
||
|
|
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||
|
|
assert len(cmd_files) > 0
|
||
|
|
for f in cmd_files:
|
||
|
|
assert f.name.startswith("speckit.")
|
||
|
|
assert f.name.endswith(".md")
|
||
|
|
|
||
|
|
def test_templates_are_processed(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
created = i.setup(
|
||
|
|
tmp_path, m,
|
||
|
|
parsed_options={"commands_dir": ".custom/cmds"},
|
||
|
|
)
|
||
|
|
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||
|
|
for f in cmd_files:
|
||
|
|
content = f.read_text(encoding="utf-8")
|
||
|
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
||
|
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
||
|
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
||
|
|
|
||
|
|
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
created = i.setup(
|
||
|
|
tmp_path, m,
|
||
|
|
parsed_options={"commands_dir": ".custom/cmds"},
|
||
|
|
)
|
||
|
|
for f in created:
|
||
|
|
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||
|
|
assert rel in m.files, f"{rel} not tracked in manifest"
|
||
|
|
|
||
|
|
def test_install_uninstall_roundtrip(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
created = i.install(
|
||
|
|
tmp_path, m,
|
||
|
|
parsed_options={"commands_dir": ".custom/cmds"},
|
||
|
|
)
|
||
|
|
assert len(created) > 0
|
||
|
|
m.save()
|
||
|
|
for f in created:
|
||
|
|
assert f.exists()
|
||
|
|
removed, skipped = i.uninstall(tmp_path, m)
|
||
|
|
assert len(removed) == len(created)
|
||
|
|
assert skipped == []
|
||
|
|
|
||
|
|
def test_modified_file_survives_uninstall(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
created = i.install(
|
||
|
|
tmp_path, m,
|
||
|
|
parsed_options={"commands_dir": ".custom/cmds"},
|
||
|
|
)
|
||
|
|
m.save()
|
||
|
|
modified = created[0]
|
||
|
|
modified.write_text("user modified this", encoding="utf-8")
|
||
|
|
removed, skipped = i.uninstall(tmp_path, m)
|
||
|
|
assert modified.exists()
|
||
|
|
assert modified in skipped
|
||
|
|
|
||
|
|
def test_different_commands_dirs(self, tmp_path):
|
||
|
|
"""Generic should work with various user-specified paths."""
|
||
|
|
for path in [".agent/commands", "tools/ai-cmds", ".custom/prompts"]:
|
||
|
|
project = tmp_path / path.replace("/", "-")
|
||
|
|
project.mkdir()
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", project)
|
||
|
|
created = i.setup(
|
||
|
|
project, m,
|
||
|
|
parsed_options={"commands_dir": path},
|
||
|
|
)
|
||
|
|
expected = project / path
|
||
|
|
assert expected.is_dir(), f"Dir {expected} not created for {path}"
|
||
|
|
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||
|
|
assert len(cmd_files) > 0
|
||
|
|
|
||
|
|
# -- Scripts ----------------------------------------------------------
|
||
|
|
|
||
|
|
def test_setup_installs_update_context_scripts(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||
|
|
scripts_dir = tmp_path / ".specify" / "integrations" / "generic" / "scripts"
|
||
|
|
assert scripts_dir.is_dir(), "Scripts directory not created for generic"
|
||
|
|
assert (scripts_dir / "update-context.sh").exists()
|
||
|
|
assert (scripts_dir / "update-context.ps1").exists()
|
||
|
|
|
||
|
|
def test_scripts_tracked_in_manifest(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||
|
|
script_rels = [k for k in m.files if "update-context" in k]
|
||
|
|
assert len(script_rels) >= 2
|
||
|
|
|
||
|
|
def test_sh_script_is_executable(self, tmp_path):
|
||
|
|
i = get_integration("generic")
|
||
|
|
m = IntegrationManifest("generic", tmp_path)
|
||
|
|
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||
|
|
sh = tmp_path / ".specify" / "integrations" / "generic" / "scripts" / "update-context.sh"
|
||
|
|
assert os.access(sh, os.X_OK)
|
||
|
|
|
||
|
|
# -- CLI --------------------------------------------------------------
|
||
|
|
|
||
|
|
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||
|
|
"""--integration generic without --ai-commands-dir should fail."""
|
||
|
|
from typer.testing import CliRunner
|
||
|
|
from specify_cli import app
|
||
|
|
runner = CliRunner()
|
||
|
|
result = runner.invoke(app, [
|
||
|
|
"init", str(tmp_path / "test-generic"), "--integration", "generic",
|
||
|
|
"--script", "sh", "--no-git",
|
||
|
|
])
|
||
|
|
# Generic requires --commands-dir / --ai-commands-dir
|
||
|
|
# The integration path validates via setup()
|
||
|
|
assert result.exit_code != 0
|
||
|
|
|
||
|
|
def test_complete_file_inventory_sh(self, tmp_path):
|
||
|
|
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
|
||
|
|
from typer.testing import CliRunner
|
||
|
|
from specify_cli import app
|
||
|
|
|
||
|
|
project = tmp_path / "inventory-generic-sh"
|
||
|
|
project.mkdir()
|
||
|
|
old_cwd = os.getcwd()
|
||
|
|
try:
|
||
|
|
os.chdir(project)
|
||
|
|
result = CliRunner().invoke(app, [
|
||
|
|
"init", "--here", "--integration", "generic",
|
||
|
|
"--ai-commands-dir", ".myagent/commands",
|
||
|
|
"--script", "sh", "--no-git",
|
||
|
|
], catch_exceptions=False)
|
||
|
|
finally:
|
||
|
|
os.chdir(old_cwd)
|
||
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||
|
|
actual = sorted(
|
||
|
|
p.relative_to(project).as_posix()
|
||
|
|
for p in project.rglob("*") if p.is_file()
|
||
|
|
)
|
||
|
|
expected = sorted([
|
||
|
|
".myagent/commands/speckit.analyze.md",
|
||
|
|
".myagent/commands/speckit.checklist.md",
|
||
|
|
".myagent/commands/speckit.clarify.md",
|
||
|
|
".myagent/commands/speckit.constitution.md",
|
||
|
|
".myagent/commands/speckit.implement.md",
|
||
|
|
".myagent/commands/speckit.plan.md",
|
||
|
|
".myagent/commands/speckit.specify.md",
|
||
|
|
".myagent/commands/speckit.tasks.md",
|
||
|
|
".myagent/commands/speckit.taskstoissues.md",
|
||
|
|
".specify/init-options.json",
|
||
|
|
".specify/integration.json",
|
||
|
|
".specify/integrations/generic.manifest.json",
|
||
|
|
".specify/integrations/generic/scripts/update-context.ps1",
|
||
|
|
".specify/integrations/generic/scripts/update-context.sh",
|
||
|
|
".specify/integrations/speckit.manifest.json",
|
||
|
|
".specify/memory/constitution.md",
|
||
|
|
".specify/scripts/bash/check-prerequisites.sh",
|
||
|
|
".specify/scripts/bash/common.sh",
|
||
|
|
".specify/scripts/bash/create-new-feature.sh",
|
||
|
|
".specify/scripts/bash/setup-plan.sh",
|
||
|
|
".specify/scripts/bash/update-agent-context.sh",
|
||
|
|
".specify/templates/agent-file-template.md",
|
||
|
|
".specify/templates/checklist-template.md",
|
||
|
|
".specify/templates/constitution-template.md",
|
||
|
|
".specify/templates/plan-template.md",
|
||
|
|
".specify/templates/spec-template.md",
|
||
|
|
".specify/templates/tasks-template.md",
|
||
|
|
])
|
||
|
|
assert actual == expected, (
|
||
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_complete_file_inventory_ps(self, tmp_path):
|
||
|
|
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script ps."""
|
||
|
|
from typer.testing import CliRunner
|
||
|
|
from specify_cli import app
|
||
|
|
|
||
|
|
project = tmp_path / "inventory-generic-ps"
|
||
|
|
project.mkdir()
|
||
|
|
old_cwd = os.getcwd()
|
||
|
|
try:
|
||
|
|
os.chdir(project)
|
||
|
|
result = CliRunner().invoke(app, [
|
||
|
|
"init", "--here", "--integration", "generic",
|
||
|
|
"--ai-commands-dir", ".myagent/commands",
|
||
|
|
"--script", "ps", "--no-git",
|
||
|
|
], catch_exceptions=False)
|
||
|
|
finally:
|
||
|
|
os.chdir(old_cwd)
|
||
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
||
|
|
actual = sorted(
|
||
|
|
p.relative_to(project).as_posix()
|
||
|
|
for p in project.rglob("*") if p.is_file()
|
||
|
|
)
|
||
|
|
expected = sorted([
|
||
|
|
".myagent/commands/speckit.analyze.md",
|
||
|
|
".myagent/commands/speckit.checklist.md",
|
||
|
|
".myagent/commands/speckit.clarify.md",
|
||
|
|
".myagent/commands/speckit.constitution.md",
|
||
|
|
".myagent/commands/speckit.implement.md",
|
||
|
|
".myagent/commands/speckit.plan.md",
|
||
|
|
".myagent/commands/speckit.specify.md",
|
||
|
|
".myagent/commands/speckit.tasks.md",
|
||
|
|
".myagent/commands/speckit.taskstoissues.md",
|
||
|
|
".specify/init-options.json",
|
||
|
|
".specify/integration.json",
|
||
|
|
".specify/integrations/generic.manifest.json",
|
||
|
|
".specify/integrations/generic/scripts/update-context.ps1",
|
||
|
|
".specify/integrations/generic/scripts/update-context.sh",
|
||
|
|
".specify/integrations/speckit.manifest.json",
|
||
|
|
".specify/memory/constitution.md",
|
||
|
|
".specify/scripts/powershell/check-prerequisites.ps1",
|
||
|
|
".specify/scripts/powershell/common.ps1",
|
||
|
|
".specify/scripts/powershell/create-new-feature.ps1",
|
||
|
|
".specify/scripts/powershell/setup-plan.ps1",
|
||
|
|
".specify/scripts/powershell/update-agent-context.ps1",
|
||
|
|
".specify/templates/agent-file-template.md",
|
||
|
|
".specify/templates/checklist-template.md",
|
||
|
|
".specify/templates/constitution-template.md",
|
||
|
|
".specify/templates/plan-template.md",
|
||
|
|
".specify/templates/spec-template.md",
|
||
|
|
".specify/templates/tasks-template.md",
|
||
|
|
])
|
||
|
|
assert actual == expected, (
|
||
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
||
|
|
)
|