mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* Initial plan * feat!: remove legacy --ai, --ai-commands-dir, and --ai-skills flags at 0.10.0 * refactor(tests): rename stale test_ai_help_* methods to test_agent_config_* * fix: address review — derive agent folder for generic integration and remove redundant test - Security notice now falls back to integration_parsed_options['commands_dir'] when AGENT_CONFIG folder is None (generic integration). - Remove test_agent_config_includes_kiro_cli which duplicates the assertion in test_runtime_config_uses_kiro_cli_and_removes_q. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub all remaining --ai flag references from source and tests - Remove dead AI_ASSISTANT_ALIASES, AI_ASSISTANT_HELP, and _build_ai_assistant_help() from _agent_config.py - Update comments/docstrings in extensions.py, presets.py, and integration subpackages to reference 'skills mode' or '--integration' instead of the removed flags - Fix catalog.json generic integration description - Update test docstrings/comments in test_extension_skills.py, test_extensions.py, and test_presets.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: remove legacy --ai flag rejection tests The flags are fully removed from the CLI; typer handles unknown options generically. No custom rejection logic exists to test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * revert: remove manual CHANGELOG.md entry CHANGELOG is generated automatically; manual edits should not be made. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make generic catalog description self-explanatory Include the required --commands-dir sub-option in the description so readers don't need to look up integration docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): rename duplicate test classes to avoid shadowing The rename from Test*AutoPromote to Test*Integration collided with the existing Test*Integration(SkillsIntegrationTests) base classes, causing the shared test suites to be silently overwritten. Rename the CLI init flow classes to Test*InitFlow instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
306 lines
13 KiB
Python
306 lines
13 KiB
Python
"""Tests for RovodevIntegration."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
|
||
import pytest
|
||
import yaml
|
||
from click.testing import Result
|
||
from typer.testing import CliRunner
|
||
|
||
from specify_cli import app
|
||
from specify_cli.integrations import get_integration
|
||
from specify_cli.integrations.manifest import IntegrationManifest
|
||
|
||
|
||
def _run_init(project, *flags: str) -> Result:
|
||
"""Run ``specify init --here`` in *project* with the given extra flags.
|
||
|
||
Centralises the cwd-management boilerplate so individual tests just
|
||
declare the flags they care about.
|
||
"""
|
||
old_cwd = os.getcwd()
|
||
try:
|
||
os.chdir(project)
|
||
return CliRunner().invoke(
|
||
app,
|
||
["init", "--here", *flags, "--script", "sh",
|
||
"--no-git", "--ignore-agent-tools"],
|
||
catch_exceptions=False,
|
||
)
|
||
finally:
|
||
os.chdir(old_cwd)
|
||
|
||
|
||
@pytest.fixture
|
||
def rovodev_init_project(tmp_path):
|
||
"""Run ``specify init --integration rovodev`` once and return the project root.
|
||
|
||
Shared across the slow init-inventory tests so we pay the full-CLI cost
|
||
only once instead of three times.
|
||
"""
|
||
project = tmp_path / "rovodev-init"
|
||
project.mkdir()
|
||
result = _run_init(project, "--integration", "rovodev")
|
||
assert result.exit_code == 0, result.output
|
||
return project
|
||
|
||
|
||
class TestRovodevIntegration:
|
||
"""Rovodev-specific tests (not inherited from SkillsIntegrationTests because
|
||
rovodev's setup() emits prompt wrappers + prompts.yml in addition to skills,
|
||
which violates the base mixin's pure-skills assumptions)."""
|
||
|
||
KEY = "rovodev"
|
||
CONTEXT_FILE = "AGENTS.md"
|
||
|
||
# -- ACLI dispatch -----------------------------------------------------
|
||
|
||
def test_build_exec_args(self):
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("/speckit.plan add OAuth")
|
||
assert args[0:3] == ["acli", "rovodev", "run"]
|
||
assert args[3] == "/speckit.plan add OAuth"
|
||
assert "--output-schema" in args
|
||
|
||
def test_build_exec_args_without_json(self):
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("/speckit.plan add OAuth", output_json=False)
|
||
assert args == ["acli", "rovodev", "run", "/speckit.plan add OAuth"]
|
||
|
||
def test_build_exec_args_executable_env_override(self, monkeypatch):
|
||
"""SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE overrides the binary path.
|
||
|
||
Lets operators pin a specific ``acli`` build or relocate the binary
|
||
without modifying the integration. Mirrors codex/devin/claude/etc.
|
||
"""
|
||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", "/opt/atl/bin/acli")
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("hello", output_json=False)
|
||
assert args == ["/opt/atl/bin/acli", "rovodev", "run", "hello"]
|
||
|
||
def test_build_exec_args_executable_env_blank_falls_back(self, monkeypatch):
|
||
"""Whitespace/empty env override is treated as unset → default ``acli``."""
|
||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXECUTABLE", " ")
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("hello", output_json=False)
|
||
assert args[0] == "acli"
|
||
|
||
def test_build_exec_args_extra_args_env_injection(self, monkeypatch):
|
||
"""SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS injects extra CLI flags.
|
||
|
||
Useful for CI or non-interactive contexts that need to pass flags
|
||
the integration doesn't expose. Mirrors the contract on every other
|
||
CLI integration (claude, codex, devin, …).
|
||
"""
|
||
monkeypatch.setenv("SPECKIT_INTEGRATION_ROVODEV_EXTRA_ARGS", "--quiet --no-color")
|
||
impl = get_integration(self.KEY)
|
||
args = impl.build_exec_args("hello", output_json=False)
|
||
assert args == [
|
||
"acli", "rovodev", "run", "hello", "--quiet", "--no-color",
|
||
]
|
||
|
||
# -- Setup-level: prompt wrappers + prompts.yml ------------------------
|
||
|
||
def test_setup_creates_prompts_and_manifest(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
created = impl.setup(tmp_path, manifest)
|
||
|
||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||
assert prompts_manifest in created
|
||
assert prompts_manifest.exists()
|
||
|
||
prompts_dir = tmp_path / ".rovodev" / "prompts"
|
||
skills_dir = tmp_path / ".rovodev" / "skills"
|
||
assert prompts_dir.is_dir()
|
||
assert skills_dir.is_dir()
|
||
|
||
templates = impl.list_command_templates()
|
||
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
|
||
skill_dirs = sorted(d for d in skills_dir.iterdir() if d.is_dir() and d.name.startswith("speckit-"))
|
||
assert len(prompt_files) == len(templates)
|
||
assert len(skill_dirs) == len(templates)
|
||
for skill_dir in skill_dirs:
|
||
assert (skill_dir / "SKILL.md").exists()
|
||
|
||
def test_prompts_manifest_entries_well_formed(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
impl.setup(tmp_path, manifest)
|
||
|
||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||
assert list(data) == ["prompts"]
|
||
entries = data["prompts"]
|
||
assert entries
|
||
for entry in entries:
|
||
assert entry["name"].startswith("speckit-")
|
||
assert entry["description"]
|
||
content_file = tmp_path / ".rovodev" / entry["content_file"]
|
||
assert content_file.exists(), f"Missing prompt file {content_file}"
|
||
|
||
def test_prompt_wrapper_format(self, tmp_path):
|
||
"""Every prompt wrapper delegates to its paired skill via 'use skill ...'."""
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
impl.setup(tmp_path, manifest)
|
||
|
||
prompts_dir = tmp_path / ".rovodev" / "prompts"
|
||
prompt_files = sorted(prompts_dir.glob("speckit-*.prompt.md"))
|
||
assert prompt_files
|
||
for prompt_file in prompt_files:
|
||
skill_name = prompt_file.name.removesuffix(".prompt.md")
|
||
content = prompt_file.read_text(encoding="utf-8")
|
||
assert content == f"use skill {skill_name} $ARGUMENTS\n", (
|
||
f"{prompt_file} has unexpected wrapper format"
|
||
)
|
||
|
||
def test_prompts_manifest_merge_preserves_user_entries(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
|
||
prompts_manifest = tmp_path / ".rovodev" / "prompts.yml"
|
||
prompts_manifest.parent.mkdir(parents=True, exist_ok=True)
|
||
user_entry = {
|
||
"name": "my-custom-prompt",
|
||
"description": "User-added prompt",
|
||
"content_file": "prompts/my-custom-prompt.md",
|
||
}
|
||
prompts_manifest.write_text(
|
||
yaml.safe_dump({"prompts": [user_entry]}, sort_keys=False),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
impl.setup(tmp_path, manifest)
|
||
|
||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||
names = {entry.get("name") for entry in data.get("prompts", [])}
|
||
assert "my-custom-prompt" in names
|
||
assert "speckit-plan" in names
|
||
|
||
def test_modified_prompts_yml_survives_uninstall(self, tmp_path):
|
||
impl = get_integration(self.KEY)
|
||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||
impl.install(tmp_path, manifest)
|
||
manifest.save()
|
||
modified = tmp_path / ".rovodev" / "prompts.yml"
|
||
modified.write_text("user modified this", encoding="utf-8")
|
||
_, skipped = impl.uninstall(tmp_path, manifest)
|
||
assert modified.exists()
|
||
assert modified in skipped
|
||
|
||
# -- Full-CLI init: skills + prompts integration with extensions -------
|
||
|
||
def test_init_inventory(self, rovodev_init_project):
|
||
"""Rovodev + extensions produce the expected skill / prompt set.
|
||
|
||
Contract:
|
||
- Rovodev.setup() emits one SKILL.md + one .prompt.md per core template.
|
||
- Extensions install additional SKILL.md directories with NO prompt wrapper.
|
||
"""
|
||
project = rovodev_init_project
|
||
impl = get_integration(self.KEY)
|
||
core_skill_names = {
|
||
f"speckit-{t.stem.replace('.', '-')}"
|
||
for t in impl.list_command_templates()
|
||
}
|
||
|
||
prompt_files = sorted((project / ".rovodev" / "prompts").glob("speckit-*.prompt.md"))
|
||
prompt_stems = {p.name.removesuffix(".prompt.md") for p in prompt_files}
|
||
|
||
skills_dir = project / ".rovodev" / "skills"
|
||
skill_names = {
|
||
d.name for d in skills_dir.iterdir()
|
||
if d.is_dir() and d.name.startswith("speckit-")
|
||
}
|
||
|
||
# Prompts: exactly the core template set.
|
||
assert prompt_stems == core_skill_names
|
||
|
||
# Skills: core ∪ extension-installed.
|
||
assert core_skill_names.issubset(skill_names)
|
||
extension_skills = skill_names - core_skill_names
|
||
assert extension_skills, (
|
||
"Expected at least one extension-installed skill (e.g. agent-context)"
|
||
)
|
||
|
||
# prompts.yml mirrors the prompt files exactly.
|
||
prompts_manifest = project / ".rovodev" / "prompts.yml"
|
||
data = yaml.safe_load(prompts_manifest.read_text(encoding="utf-8"))
|
||
assert {e["name"] for e in data["prompts"]} == core_skill_names
|
||
|
||
def test_init_skill_files_well_formed(self, rovodev_init_project):
|
||
"""Every speckit-* SKILL.md from full init has valid frontmatter +
|
||
processed body, including extension-installed skills."""
|
||
project = rovodev_init_project
|
||
skills_dir = project / ".rovodev" / "skills"
|
||
skill_dirs = sorted(
|
||
d for d in skills_dir.iterdir()
|
||
if d.is_dir() and d.name.startswith("speckit-")
|
||
)
|
||
assert skill_dirs
|
||
|
||
for skill_dir in skill_dirs:
|
||
skill_file = skill_dir / "SKILL.md"
|
||
assert skill_file.exists(), f"Missing {skill_file}"
|
||
content = skill_file.read_text(encoding="utf-8")
|
||
|
||
# Frontmatter delimited by leading '---\n' ... '\n---\n'
|
||
assert content.startswith("---\n"), f"{skill_file} missing frontmatter"
|
||
fm_end = content.find("\n---\n", 4)
|
||
assert fm_end != -1, f"{skill_file} has unterminated frontmatter"
|
||
fm = yaml.safe_load(content[4:fm_end])
|
||
body = content[fm_end + len("\n---\n"):]
|
||
|
||
assert fm.get("name") == skill_dir.name
|
||
assert fm.get("description")
|
||
assert body.strip(), f"{skill_file} has empty body"
|
||
|
||
for placeholder in ("{SCRIPT}", "__AGENT__", "__CONTEXT_FILE__", "__SPECKIT_COMMAND_"):
|
||
assert placeholder not in body, (
|
||
f"{skill_file} body contains unprocessed placeholder {placeholder!r}"
|
||
)
|
||
# Skills agents must use hyphen-style refs in body.
|
||
assert "/speckit." not in body, (
|
||
f"{skill_file} body contains dot-notation /speckit. reference"
|
||
)
|
||
|
||
# The plan skill must reference the agent's context file.
|
||
plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8")
|
||
assert self.CONTEXT_FILE in plan_content
|
||
|
||
# -- Full-CLI init: integration metadata -------------------------------
|
||
|
||
def test_init_writes_integration_manifest_and_options(self, rovodev_init_project):
|
||
"""Full init must produce an integration manifest and well-formed
|
||
init-options.json — used by extensions, presets, and uninstall."""
|
||
import json
|
||
|
||
project = rovodev_init_project
|
||
|
||
manifest_path = project / ".specify" / "integrations" / "rovodev.manifest.json"
|
||
speckit_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||
assert manifest_path.exists(), "rovodev integration manifest missing"
|
||
assert speckit_manifest.exists(), "speckit shared manifest missing"
|
||
|
||
init_options = json.loads(
|
||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||
)
|
||
assert init_options["integration"] == self.KEY
|
||
assert init_options["ai"] == self.KEY
|
||
# Rovodev is a SkillsIntegration, so ai_skills is auto-set.
|
||
assert init_options.get("ai_skills") is True
|
||
assert init_options.get("script") == "sh"
|
||
|
||
def test_integration_flag_creates_expected_files(self, tmp_path):
|
||
"""``--integration rovodev`` should create all expected rovodev files."""
|
||
project = tmp_path / "rovodev-int"
|
||
project.mkdir()
|
||
result = _run_init(project, "--integration", "rovodev")
|
||
assert result.exit_code == 0, result.output
|
||
assert (project / ".rovodev" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||
assert (project / ".rovodev" / "prompts.yml").exists()
|
||
assert (project / ".specify" / "integrations" / "rovodev.manifest.json").exists()
|