Files
github-spec-kit/tests/integrations/test_integration_rovodev.py
Samir Abed 34ce66139e feat: add support for rovodev (#2539)
* feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev

* fixup! feat: add support for rovodev
2026-06-04 11:34:05 -05:00

306 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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_ai_flag_auto_promotes_to_integration(self, tmp_path):
"""``--ai rovodev`` should reach the same end-state as ``--integration rovodev``."""
project = tmp_path / "rovodev-ai"
project.mkdir()
result = _run_init(project, "--ai", "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()