chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Unit tests for Spec Kit."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Shared test helpers for authentication config injection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from specify_cli.authentication.config import AuthConfigEntry
|
||||
|
||||
|
||||
def make_github_auth_entry(token_env: str = "GH_TOKEN") -> AuthConfigEntry:
|
||||
"""Build a GitHub ``AuthConfigEntry`` for testing."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
def inject_github_config(monkeypatch, token_env: str = "GH_TOKEN") -> None:
|
||||
"""Inject a GitHub auth.json config entry into the auth HTTP module."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [make_github_auth_entry(token_env)])
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Shared helpers and fakes for bundler tests.
|
||||
|
||||
Kept out of ``tests/conftest.py`` so the existing root fixtures are untouched.
|
||||
Import what you need explicitly, e.g.::
|
||||
|
||||
from tests.bundler_helpers import FakeInstaller, write_manifest
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
|
||||
|
||||
def valid_manifest_dict(**overrides) -> dict:
|
||||
"""Return a structurally valid manifest dict; override any top-level key."""
|
||||
data = {
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "demo-bundle",
|
||||
"name": "Demo Bundle",
|
||||
"version": "1.2.0",
|
||||
"role": "developer",
|
||||
"description": "A demo bundle for tests.",
|
||||
"author": "Spec Kit",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"extensions": [{"id": "ext-a", "version": "1.0.0"}],
|
||||
"presets": [
|
||||
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
|
||||
],
|
||||
"steps": [{"id": "step-a"}],
|
||||
"workflows": [{"id": "wf-a", "version": "0.3.0"}],
|
||||
},
|
||||
"tags": ["demo", "test"],
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def write_manifest(directory: Path, data: dict | None = None) -> Path:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
manifest_path = directory / "bundle.yml"
|
||||
manifest_path.write_text(
|
||||
yaml.safe_dump(data if data is not None else valid_manifest_dict()),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return manifest_path
|
||||
|
||||
|
||||
def make_project(root: Path) -> Path:
|
||||
"""Create a minimal Spec Kit project skeleton under *root*."""
|
||||
(root / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
return root
|
||||
|
||||
|
||||
def catalog_payload(bundles: dict | None = None) -> dict:
|
||||
return {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-06-19T00:00:00Z",
|
||||
"catalog_url": "file://test",
|
||||
"bundles": bundles or {},
|
||||
}
|
||||
|
||||
|
||||
def catalog_entry_dict(bundle_id: str = "demo-bundle", **overrides) -> dict:
|
||||
entry = {
|
||||
"id": bundle_id,
|
||||
"name": "Demo Bundle",
|
||||
"version": "1.2.0",
|
||||
"role": "developer",
|
||||
"description": "A demo bundle.",
|
||||
"author": "Spec Kit",
|
||||
"license": "MIT",
|
||||
"download_url": "",
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"extensions": 1, "presets": 1, "steps": 1, "workflows": 1},
|
||||
"verified": True,
|
||||
}
|
||||
entry.update(overrides)
|
||||
return entry
|
||||
|
||||
|
||||
def write_catalog_file(path: Path, bundles: dict) -> Path:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(catalog_payload(bundles)), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class FakeInstaller:
|
||||
"""Deterministic in-memory PrimitiveInstaller for offline integration tests."""
|
||||
|
||||
def __init__(self, *, fail_on: str | None = None) -> None:
|
||||
self.installed: set[tuple[str, str]] = set()
|
||||
self.install_calls: list[tuple[str, str]] = []
|
||||
self.remove_calls: list[tuple[str, str]] = []
|
||||
self.refresh_calls: list[tuple[str, str]] = []
|
||||
self._fail_on = fail_on
|
||||
|
||||
def _key(self, component: ComponentRef) -> tuple[str, str]:
|
||||
return (component.kind, component.id)
|
||||
|
||||
def is_installed(self, project_root: Path, component: ComponentRef) -> bool:
|
||||
return self._key(component) in self.installed
|
||||
|
||||
def install(self, project_root: Path, component: ComponentRef) -> None:
|
||||
from specify_cli.bundler import BundlerError
|
||||
|
||||
self.install_calls.append(self._key(component))
|
||||
if self._fail_on is not None and component.id == self._fail_on:
|
||||
raise BundlerError(f"Simulated failure installing {component.id}")
|
||||
self.installed.add(self._key(component))
|
||||
|
||||
def remove(self, project_root: Path, component: ComponentRef) -> None:
|
||||
self.remove_calls.append(self._key(component))
|
||||
self.installed.discard(self._key(component))
|
||||
|
||||
def refresh(self, project_root: Path, component: ComponentRef) -> None:
|
||||
self.refresh_calls.append(self._key(component))
|
||||
self.installed.add(self._key(component))
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Shared test helpers for the Spec Kit test suite."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||
|
||||
|
||||
def _has_working_bash() -> bool:
|
||||
"""Check whether a functional native bash is available.
|
||||
|
||||
On Windows, ``subprocess.run(["bash", ...])`` uses CreateProcess,
|
||||
which searches System32 *before* PATH — so it may find the WSL
|
||||
launcher even when Git-for-Windows bash appears first in PATH via
|
||||
``shutil.which``. We therefore probe with bare ``"bash"`` (the
|
||||
same way test helpers invoke it) to get an accurate result.
|
||||
|
||||
On Windows, only Git-for-Windows bash (MSYS2/MINGW) is accepted.
|
||||
The WSL launcher is rejected because it runs in a separate Linux
|
||||
filesystem and cannot handle native Windows paths used by the
|
||||
test fixtures.
|
||||
|
||||
Set SPECKIT_TEST_BASH=1 to force-enable bash tests regardless.
|
||||
"""
|
||||
if os.environ.get("SPECKIT_TEST_BASH") == "1":
|
||||
return True
|
||||
if shutil.which("bash") is None:
|
||||
return False
|
||||
# Probe with bare "bash" — same as the test helpers — so that
|
||||
# Windows CreateProcess resolution order is respected.
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["bash", "-c", "echo ok"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if r.returncode != 0 or "ok" not in r.stdout:
|
||||
return False
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
# On Windows, verify we have MSYS/MINGW bash (Git for Windows),
|
||||
# not the WSL launcher which can't handle native paths.
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
u = subprocess.run(
|
||||
["bash", "-c", "uname -s"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
kernel = u.stdout.strip().upper()
|
||||
if not any(k in kernel for k in ("MSYS", "MINGW", "CYGWIN")):
|
||||
return False
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
requires_bash = pytest.mark.skipif(
|
||||
not _has_working_bash(), reason="working bash not available"
|
||||
)
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Remove ANSI escape codes from Rich-formatted CLI output."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth config isolation — prevents tests from reading ~/.specify/auth.json
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_auth_config(monkeypatch):
|
||||
"""Ensure no test reads the real ~/.specify/auth.json."""
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", [])
|
||||
# Also clear the per-process cache so tests that unset _config_override
|
||||
# won't see a previously cached real-file result.
|
||||
monkeypatch.setattr(_auth_http, "_config_cache", None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _strip_specify_env(monkeypatch):
|
||||
"""Drop any inherited SPECIFY_* vars for every test.
|
||||
|
||||
The Python CLI's project resolver (`_require_specify_project`) now honors
|
||||
SPECIFY_INIT_DIR, and the shell resolvers honor SPECIFY_FEATURE* — so a
|
||||
developer or CI runner with any SPECIFY_* var exported would silently
|
||||
retarget (or hard-error) the many command/script tests that resolve a
|
||||
project. Stripping them here keeps resolution tests deterministic; a test
|
||||
that wants an override sets it explicitly via monkeypatch afterwards."""
|
||||
for key in [k for k in os.environ if k.startswith("SPECIFY_")]:
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_environ(monkeypatch):
|
||||
"""Strip any real GH_TOKEN / GITHUB_TOKEN from the test environment."""
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
||||
|
||||
|
||||
def _fake_self_upgrade_argv0(monkeypatch, tmp_path, env_name, path_parts):
|
||||
"""Create a fake executable under tmp_path and point sys.argv[0] at it."""
|
||||
monkeypatch.setenv(env_name, str(tmp_path))
|
||||
fake_dir = tmp_path.joinpath(*path_parts)
|
||||
fake_dir.mkdir(parents=True)
|
||||
fake_specify = fake_dir / ("specify.exe" if os.name == "nt" else "specify")
|
||||
fake_specify.write_text("#!/usr/bin/env python\n")
|
||||
fake_specify.chmod(0o755)
|
||||
monkeypatch.setattr("sys.argv", [str(fake_specify)])
|
||||
return fake_specify
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uv_tool_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated `uv tool` install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("uv", "tools", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"HOME",
|
||||
(".local", "share", "uv", "tools", "specify-cli", "bin"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipx_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated pipx install path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "LOCALAPPDATA", ("pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".local", "pipx", "venvs", "specify-cli", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uvx_ephemeral_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a simulated uvx ephemeral-cache path under tmp HOME."""
|
||||
if os.name == "nt":
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
"LOCALAPPDATA",
|
||||
("uv", "cache", "archive-v0", "abc123", "bin"),
|
||||
)
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", (".cache", "uv", "archive-v0", "abc123", "bin")
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unsupported_argv0(monkeypatch, tmp_path):
|
||||
"""Point sys.argv[0] at a path that does not match any installer prefix."""
|
||||
return _fake_self_upgrade_argv0(
|
||||
monkeypatch, tmp_path, "HOME", ("random", "location", "bin")
|
||||
)
|
||||
@@ -0,0 +1,719 @@
|
||||
"""Contract test for the `specify bundle` CLI surface (Typer integration).
|
||||
|
||||
Exercises the wired commands end-to-end via CliRunner against a temp project,
|
||||
asserting exit codes and the cross-cutting error guarantees from
|
||||
contracts/cli-commands.md (offline, discovery-only refusal, not-a-project error).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import (
|
||||
catalog_entry_dict,
|
||||
valid_manifest_dict,
|
||||
write_catalog_file,
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def project(tmp_path: Path, monkeypatch) -> Path:
|
||||
(tmp_path / ".specify").mkdir()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_bundle_help_lists_all_commands():
|
||||
result = runner.invoke(app, ["bundle", "--help"])
|
||||
assert result.exit_code == 0
|
||||
for cmd in ("search", "info", "list", "install", "update", "remove",
|
||||
"validate", "build", "init", "catalog"):
|
||||
assert cmd in result.output
|
||||
|
||||
|
||||
def test_update_accepts_integration_override():
|
||||
# Update must expose --integration so integration-pinned bundles can be
|
||||
# updated in projects where the active integration can't be auto-detected.
|
||||
# Rich may insert ANSI escapes between the two leading dashes, so match the
|
||||
# un-split option word rather than the literal "--integration".
|
||||
result = runner.invoke(app, ["bundle", "update", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "integration" in result.output
|
||||
|
||||
|
||||
def test_list_empty_project(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No bundles installed" in result.output
|
||||
|
||||
|
||||
def test_commands_outside_project_fail_with_guidance(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 1
|
||||
assert "Spec Kit project" in result.output
|
||||
|
||||
|
||||
def test_fail_writes_error_to_stderr_not_stdout(capsys):
|
||||
"""_fail must write to stderr, not stdout: every bundle command routes errors
|
||||
through it, and under --json the error would otherwise corrupt the JSON payload
|
||||
that consumers read from stdout."""
|
||||
import typer
|
||||
|
||||
from specify_cli.commands.bundle import _fail
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_fail("something broke")
|
||||
captured = capsys.readouterr()
|
||||
assert "something broke" in captured.err
|
||||
assert "something broke" not in captured.out
|
||||
|
||||
|
||||
def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
|
||||
# Discovery commands fall back to the built-in/user catalog stack and must
|
||||
# not require a Spec Kit project (matches README/quickstart examples).
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.strip().startswith("[")
|
||||
|
||||
|
||||
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.chdir(tmp_path) # no .specify/
|
||||
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
|
||||
# Reaches catalog resolution (not the project gate) and reports a clean miss.
|
||||
assert result.exit_code == 1
|
||||
assert "Spec Kit project" not in result.output
|
||||
|
||||
|
||||
def test_catalog_list_shows_builtin_defaults(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "catalog", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "default" in result.output
|
||||
assert "community" in result.output
|
||||
assert "built-in default stack" in result.output
|
||||
|
||||
|
||||
def test_catalog_add_and_remove(project: Path):
|
||||
catalog = project / "local-catalog.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
listed = runner.invoke(app, ["bundle", "catalog", "list"])
|
||||
assert "local" in listed.output
|
||||
|
||||
removed = runner.invoke(app, ["bundle", "catalog", "remove", "local"])
|
||||
assert removed.exit_code == 0
|
||||
|
||||
|
||||
def test_catalog_remove_builtin_is_refused(project: Path):
|
||||
result = runner.invoke(app, ["bundle", "catalog", "remove", "default"])
|
||||
assert result.exit_code == 1
|
||||
assert "built-in" in result.output
|
||||
|
||||
|
||||
def test_validate_reports_invalid_manifest(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 1
|
||||
assert "license" in result.output
|
||||
|
||||
|
||||
def test_validate_accepts_valid_manifest(project: Path):
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
# Offline mode does not fail on references it cannot verify (synthetic ids
|
||||
# here); they surface as warnings while structure is confirmed valid.
|
||||
result = runner.invoke(app, ["bundle", "validate", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "valid" in result.output
|
||||
|
||||
|
||||
def test_validate_rejects_broken_reference(project: Path):
|
||||
# Synthetic component ids resolve to nothing in any catalog → hard failure.
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 1
|
||||
assert "preset-a" in result.output or "ext-a" in result.output
|
||||
|
||||
|
||||
def test_validate_accepts_bundled_reference(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]}
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "validate"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "valid" in result.output
|
||||
|
||||
|
||||
def test_build_produces_artifact(project: Path):
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(project / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
result = runner.invoke(app, ["bundle", "build", "--output", str(project / "dist")])
|
||||
assert result.exit_code == 0, result.output
|
||||
artifacts = list((project / "dist").glob("*.zip"))
|
||||
assert len(artifacts) == 1
|
||||
|
||||
|
||||
def test_info_expands_full_component_set(project: Path):
|
||||
bundle_dir = project / "src-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
catalog = project / "local-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]): c for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
preset = components[("presets", "preset-a")]
|
||||
assert preset["version"] == "2.0.0"
|
||||
assert preset["priority"] == 10
|
||||
assert preset["strategy"] == "append"
|
||||
assert payload["trust"] == "verified"
|
||||
|
||||
text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
|
||||
assert "preset-a v2.0.0" in text.output
|
||||
assert "Trust" in text.output
|
||||
|
||||
|
||||
def test_info_expands_discovery_only_bundle(project: Path):
|
||||
# Discovery-only bundles must still be fully inspectable via `info`;
|
||||
# only `install` is refused for them.
|
||||
bundle_dir = project / "disc-bundle"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
catalog = project / "disc-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url=str(bundle_dir / "bundle.yml")
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]) for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_info_resolves_local_zip_download_url(project: Path):
|
||||
# A local .zip artifact as download_url is extracted to read bundle.yml.
|
||||
bundle_dir = project / "zip-src"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(bundle_dir / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
artifact = build_bundle(bundle_dir, output_dir=project / "dist").artifact_path
|
||||
catalog = project / "zip-catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=str(artifact))},
|
||||
)
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "local"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
components = {(c["kind"], c["id"]) for c in payload["components"]}
|
||||
assert ("extensions", "ext-a") in components
|
||||
|
||||
|
||||
def test_install_refuses_discovery_only_source(project: Path, monkeypatch):
|
||||
# Point a discovery-only catalog at a local payload containing the bundle.
|
||||
catalog = project / "disc.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "install", "demo", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "discovery-only" in result.output
|
||||
|
||||
|
||||
def test_update_refuses_discovery_only_source(project: Path):
|
||||
# An installed bundle whose only resolvable source is discovery-only must
|
||||
# not be updatable from there (FR-025), mirroring the install policy gate.
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
from specify_cli.bundler.models.records import (
|
||||
InstalledBundleRecord,
|
||||
save_records,
|
||||
)
|
||||
|
||||
save_records(
|
||||
project,
|
||||
[
|
||||
InstalledBundleRecord.create(
|
||||
"demo",
|
||||
"1.0.0",
|
||||
[ComponentRef(kind="extensions", id="ext-a", version=None)],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
catalog = project / "disc.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "disc", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "discovery-only"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["bundle", "update", "demo", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "discovery-only" in result.output
|
||||
|
||||
|
||||
def test_info_fails_loudly_when_manifest_unresolvable_offline(project: Path):
|
||||
# `info` must expand the real component set; if the manifest can't be
|
||||
# resolved (here: --offline against an https download_url), it should error
|
||||
# and exit non-zero rather than silently degrading to `provides` counts.
|
||||
catalog = project / "remote-catalog.json"
|
||||
entry = catalog_entry_dict(
|
||||
"demo-bundle", download_url="https://example.com/demo-bundle.zip"
|
||||
)
|
||||
write_catalog_file(catalog, {"demo-bundle": entry})
|
||||
added = runner.invoke(
|
||||
app, ["bundle", "catalog", "add", str(catalog), "--id", "remote"]
|
||||
)
|
||||
assert added.exit_code == 0, added.output
|
||||
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"])
|
||||
assert result.exit_code == 1
|
||||
assert "Network access disabled" in result.output
|
||||
|
||||
|
||||
def test_search_json_offline(project: Path):
|
||||
catalog = project / "c.json"
|
||||
write_catalog_file(catalog, {"demo": catalog_entry_dict("demo")})
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "c", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "install-allowed"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload[0]["id"] == "demo"
|
||||
# Trust indicator is exposed on the discovery surface (FR-010 / FR-027).
|
||||
assert payload[0]["verified"] is True
|
||||
assert payload[0]["trust"] == "verified"
|
||||
|
||||
|
||||
def test_search_text_shows_trust(project: Path):
|
||||
catalog = project / "c.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{
|
||||
"verified-one": catalog_entry_dict("verified-one", verified=True),
|
||||
"community-one": catalog_entry_dict("community-one", verified=False),
|
||||
},
|
||||
)
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "c", "url": str(catalog), "priority": 1,
|
||||
"install_policy": "install-allowed"}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
result = runner.invoke(app, ["bundle", "search", "--offline"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "verified" in result.output
|
||||
assert "community" in result.output
|
||||
|
||||
|
||||
def test_install_integration_override_cannot_bypass_clash_guard(project: Path):
|
||||
# An initialized project's recorded active integration is authoritative:
|
||||
# passing --integration must not let a differently-pinned bundle install.
|
||||
import json
|
||||
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
json.dumps({"integration": "copilot"}), encoding="utf-8"
|
||||
)
|
||||
bundle_dir = project / "claude-bundle"
|
||||
bundle_dir.mkdir()
|
||||
data = valid_manifest_dict(integration={"id": "claude"})
|
||||
(bundle_dir / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
(bundle_dir / "README.md").write_text("# Claude bundle", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["bundle", "install", str(bundle_dir), "--integration", "claude", "--offline"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "claude" in result.output and "copilot" in result.output
|
||||
|
||||
|
||||
# ===== Private GitHub release asset URL resolution =====
|
||||
|
||||
|
||||
class FakeBundleResponse:
|
||||
"""Minimal context-manager response stub for open_url fakes."""
|
||||
|
||||
def __init__(self, data: bytes, url: str = "https://api.github.com/repos/org/repo/releases/assets/99"):
|
||||
self._data = data
|
||||
self._url = url
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
def geturl(self) -> str:
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
|
||||
def _make_catalog_config(catalog_path: Path, project: Path) -> None:
|
||||
"""Write a bundle-catalogs.yml pointing at *catalog_path* in *project*."""
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{
|
||||
"id": "test",
|
||||
"url": str(catalog_path),
|
||||
"priority": 1,
|
||||
"install_policy": "install-allowed",
|
||||
}
|
||||
],
|
||||
}
|
||||
(project / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def test_bundle_info_resolves_github_browser_release_url(project: Path):
|
||||
"""bundle info resolves a private-repo browser release URL via the GitHub API."""
|
||||
browser_url = "https://github.com/org/repo/releases/download/v1.0/bundle.yml"
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
|
||||
captured = []
|
||||
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
# GitHub API release-tags lookup — return asset list
|
||||
return FakeBundleResponse(
|
||||
json.dumps({
|
||||
"assets": [{"name": "bundle.yml", "url": api_asset_url}]
|
||||
}).encode(),
|
||||
url=url,
|
||||
)
|
||||
# Actual asset download
|
||||
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# The browser release URL must have been resolved via the GitHub tags API
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1, f"Expected exactly one tags API call; got {captured}"
|
||||
assert "releases/tags/v1.0" in tag_calls[0]
|
||||
|
||||
# The actual download must use the resolved API asset URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
def test_bundle_info_passes_through_api_asset_url(project: Path):
|
||||
"""bundle info passes a direct GitHub API asset URL through with octet-stream."""
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/77"
|
||||
|
||||
captured = []
|
||||
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# No tags API call — URL was already a REST asset URL
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 0
|
||||
|
||||
# Exactly one download call to the asset URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
def test_bundle_info_resolves_github_browser_release_url_zip(project: Path):
|
||||
"""bundle info resolves a browser release URL for a .zip artifact and extracts bundle.yml."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
browser_url = "https://github.com/org/repo/releases/download/v2.0/bundle.zip"
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/88"
|
||||
|
||||
# Build a minimal in-memory ZIP containing bundle.yml
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
|
||||
zip_bytes = buf.getvalue()
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
return FakeBundleResponse(
|
||||
json.dumps({
|
||||
"assets": [{"name": "bundle.zip", "url": api_asset_url}]
|
||||
}).encode(),
|
||||
url=url,
|
||||
)
|
||||
return FakeBundleResponse(zip_bytes, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# tags API lookup must have fired
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert "releases/tags/v2.0" in tag_calls[0]
|
||||
|
||||
# Asset download uses the resolved API URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
# Manifest was successfully parsed from the ZIP
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
|
||||
|
||||
def test_bundle_info_api_asset_url_zip_detected_by_magic_bytes(project: Path):
|
||||
"""bundle info correctly handles a direct API asset URL that serves ZIP bytes."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/55"
|
||||
|
||||
# Build a minimal in-memory ZIP containing bundle.yml
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
|
||||
zip_bytes = buf.getvalue()
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
return FakeBundleResponse(zip_bytes, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# No tags API call — URL was already a REST asset URL
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 0
|
||||
|
||||
# Download used octet-stream header
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
# ZIP bytes were detected by magic and bundle.yml extracted correctly
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
|
||||
|
||||
def test_bundle_info_github_release_url_resolution_failure_falls_back_and_errors(project: Path):
|
||||
"""When the GitHub tags API lookup finds no matching asset, fall back to the
|
||||
original browser URL and surface a meaningful error (not a raw traceback)."""
|
||||
browser_url = "https://github.com/org/repo/releases/download/v3.0/bundle.yml"
|
||||
|
||||
captured = []
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "releases/tags/" in url:
|
||||
# Tags API responds but the asset list doesn't include our file
|
||||
return FakeBundleResponse(
|
||||
json.dumps({"assets": []}).encode(),
|
||||
url=url,
|
||||
)
|
||||
# Fallback download: GitHub serves HTML (SSO redirect) instead of YAML
|
||||
return FakeBundleResponse(b"<html>SSO login required</html>", url=url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
# Must exit non-zero — the HTML body is not a valid bundle manifest
|
||||
assert result.exit_code == 1
|
||||
|
||||
# The tags API lookup must have fired
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
|
||||
# The fallback download should use the original browser URL (no octet-stream)
|
||||
fallback_calls = [(url, h) for url, h in captured if url == browser_url]
|
||||
assert len(fallback_calls) == 1
|
||||
assert fallback_calls[0][1] is None # no Accept header on the original URL
|
||||
|
||||
# Error output must be actionable (not a raw traceback)
|
||||
assert "Error:" in result.output
|
||||
|
||||
|
||||
def test_bundle_info_resolves_ghes_browser_release_url(project: Path):
|
||||
"""bundle info resolves a GHES private-repo browser release URL via /api/v3."""
|
||||
ghes_host = "ghes.example"
|
||||
browser_url = f"https://{ghes_host}/org/repo/releases/download/v1.0/bundle.yml"
|
||||
api_asset_url = f"https://{ghes_host}/api/v3/repos/org/repo/releases/assets/42"
|
||||
|
||||
captured = []
|
||||
manifest_yaml = yaml.safe_dump(valid_manifest_dict()).encode()
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
captured.append((url, extra_headers))
|
||||
if "/api/v3/repos/" in url and "releases/tags/" in url:
|
||||
return FakeBundleResponse(
|
||||
json.dumps({
|
||||
"assets": [{"name": "bundle.yml", "url": api_asset_url}]
|
||||
}).encode(),
|
||||
url=url,
|
||||
)
|
||||
return FakeBundleResponse(manifest_yaml, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=browser_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
|
||||
patch("specify_cli.authentication.http.github_provider_hosts", return_value=(ghes_host,)):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# The GHES /api/v3 tags lookup must have fired
|
||||
tag_calls = [url for url, _ in captured if "releases/tags/" in url]
|
||||
assert len(tag_calls) == 1
|
||||
assert f"{ghes_host}/api/v3/repos/org/repo/releases/tags/v1.0" in tag_calls[0]
|
||||
|
||||
# Asset download must use the resolved GHES API URL with octet-stream
|
||||
asset_calls = [(url, h) for url, h in captured if "releases/assets/" in url]
|
||||
assert len(asset_calls) == 1
|
||||
assert asset_calls[0][0] == api_asset_url
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Contract tests for the catalog schema and source stack.
|
||||
|
||||
Mirrors contracts/bundle-catalog.schema.md: source precedence project > user >
|
||||
built-in, install policy gating, payload parsing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.models.catalog import (
|
||||
BUILTIN_DEFAULT_STACK,
|
||||
CatalogSource,
|
||||
InstallPolicy,
|
||||
Scope,
|
||||
load_catalog_payload,
|
||||
load_source_stack,
|
||||
)
|
||||
from specify_cli.bundler import BundlerError
|
||||
import pytest
|
||||
from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project
|
||||
|
||||
|
||||
def test_non_integer_source_priority_raises_actionable_error():
|
||||
with pytest.raises(BundlerError, match="non-integer priority"):
|
||||
CatalogSource.from_dict(
|
||||
{"id": "corp", "url": "https://corp/catalog.json", "priority": "high"},
|
||||
Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_default_stack_when_no_config(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
sources = load_source_stack(tmp_path)
|
||||
ids = [s.id for s in sources]
|
||||
assert ids == ["default", "community"]
|
||||
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
|
||||
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
|
||||
assert all(s.scope is Scope.BUILTIN for s in sources)
|
||||
|
||||
|
||||
def test_project_config_overrides_same_id(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
config = {
|
||||
"schema_version": "1.0",
|
||||
"catalogs": [
|
||||
{"id": "default", "url": "file://local", "priority": 1,
|
||||
"install_policy": "install-allowed"},
|
||||
{"id": "corp", "url": "https://corp/catalog.json", "priority": 0,
|
||||
"install_policy": "install-allowed"},
|
||||
],
|
||||
}
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
sources = load_source_stack(tmp_path)
|
||||
by_id = {s.id: s for s in sources}
|
||||
assert by_id["default"].scope is Scope.PROJECT
|
||||
assert by_id["default"].url == "file://local"
|
||||
# Highest precedence (lowest priority number) sorts first.
|
||||
assert sources[0].id == "corp"
|
||||
|
||||
|
||||
def test_user_scope_between_builtin_and_project(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
user_dir = tmp_path / "userconf"
|
||||
user_dir.mkdir()
|
||||
(user_dir / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"catalogs": [
|
||||
{"id": "community", "url": "https://u", "priority": 2,
|
||||
"install_policy": "install-allowed"}
|
||||
]}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
sources = load_source_stack(tmp_path, user_config_dir=user_dir)
|
||||
by_id = {s.id: s for s in sources}
|
||||
# User overrode the built-in community policy to install-allowed.
|
||||
assert by_id["community"].scope is Scope.USER
|
||||
assert by_id["community"].install_allowed is True
|
||||
|
||||
|
||||
def test_load_payload_parses_entries():
|
||||
payload = catalog_payload({"demo-bundle": catalog_entry_dict()})
|
||||
entries = load_catalog_payload(payload)
|
||||
assert "demo-bundle" in entries
|
||||
assert entries["demo-bundle"].version == "1.2.0"
|
||||
assert entries["demo-bundle"].provides["presets"] == 1
|
||||
|
||||
|
||||
def test_builtin_default_stack_constant_shape():
|
||||
ids = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
|
||||
assert ids == {"default", "community"}
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_string_tags():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["tags"] = "not-a-list"
|
||||
with pytest.raises(BundlerError, match="'tags' must be a list"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_boolean_verified():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["verified"] = "false" # truthy string must not mark the entry verified
|
||||
with pytest.raises(BundlerError, match="'verified' must be a boolean"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_load_payload_rejects_id_key_mismatch():
|
||||
# The enclosing key is authoritative; an entry whose own id disagrees with
|
||||
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.
|
||||
payload = catalog_payload({"demo-bundle": catalog_entry_dict("other-id")})
|
||||
with pytest.raises(BundlerError, match="id mismatch"):
|
||||
load_catalog_payload(payload)
|
||||
|
||||
|
||||
def test_load_payload_rejects_missing_entry_id():
|
||||
entry = catalog_entry_dict("demo-bundle")
|
||||
entry["id"] = ""
|
||||
payload = catalog_payload({"demo-bundle": entry})
|
||||
with pytest.raises(BundlerError, match="missing its 'id'"):
|
||||
load_catalog_payload(payload)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_mapping_requires():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["requires"] = "speckit>=0.1"
|
||||
with pytest.raises(BundlerError, match="'requires' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_catalog_entry_rejects_non_mapping_provides():
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data["provides"] = "extensions"
|
||||
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Contract tests for the bundle manifest schema (bundle.yml).
|
||||
|
||||
Mirrors contracts/bundle-manifest.schema.md: required identity/metadata fields,
|
||||
semver pinning of components, preset priority+strategy, integration optionality.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
|
||||
def test_valid_manifest_has_no_structural_errors():
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
assert manifest.structural_errors() == []
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
assert manifest.is_agnostic() is True
|
||||
|
||||
|
||||
def test_missing_required_field_is_reported_by_name():
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("bundle.license" in e for e in errors)
|
||||
|
||||
|
||||
def test_unsupported_schema_version_is_rejected():
|
||||
data = valid_manifest_dict(schema_version="9.9")
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("schema_version" in e for e in errors)
|
||||
|
||||
|
||||
def test_non_semver_bundle_version_is_rejected():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["version"] = "not-a-version"
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("semver" in e for e in errors)
|
||||
|
||||
|
||||
def test_preset_requires_priority_and_strategy():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"] = [{"id": "p", "version": "1.0.0"}]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("priority" in e for e in errors)
|
||||
assert any("strategy" in e for e in errors)
|
||||
|
||||
|
||||
def test_invalid_preset_strategy_is_rejected():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"][0]["strategy"] = "merge"
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("strategy" in e for e in errors)
|
||||
|
||||
|
||||
def test_non_integer_priority_raises_actionable_error():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["presets"][0]["priority"] = "high"
|
||||
with pytest.raises(BundlerError, match="priority must be an integer"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_non_step_components_must_be_pinned():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["extensions"] = [{"id": "ext-unpinned"}]
|
||||
errors = BundleManifest.from_dict(data).structural_errors()
|
||||
assert any("must be pinned" in e for e in errors)
|
||||
|
||||
|
||||
def test_steps_may_be_unpinned():
|
||||
data = valid_manifest_dict()
|
||||
data["provides"]["steps"] = [{"id": "step-x"}]
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.structural_errors() == []
|
||||
|
||||
|
||||
def test_integration_makes_bundle_non_agnostic():
|
||||
data = valid_manifest_dict(integration={"id": "copilot"})
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert manifest.is_agnostic() is False
|
||||
assert manifest.integration.id == "copilot"
|
||||
|
||||
|
||||
def test_components_property_orders_by_kind():
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
kinds = [c.kind for c in manifest.components]
|
||||
assert kinds == ["extensions", "presets", "steps", "workflows"]
|
||||
|
||||
|
||||
def test_string_tags_rejected_not_split_per_character():
|
||||
# A bare string would otherwise be iterated character-by-character; the
|
||||
# schema requires a list of strings.
|
||||
data = valid_manifest_dict()
|
||||
data["tags"] = "security"
|
||||
with pytest.raises(BundlerError, match="'tags' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_unsafe_bundle_id_flagged_by_structural_validation():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "../evil"
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
errors = manifest.structural_errors()
|
||||
assert any("bundle.id" in e and "slug" in e for e in errors)
|
||||
|
||||
|
||||
def test_valid_slug_bundle_id_passes():
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = "team-a.bundle_1"
|
||||
manifest = BundleManifest.from_dict(data)
|
||||
assert not any("bundle.id" in e for e in manifest.structural_errors())
|
||||
|
||||
|
||||
def test_string_tools_rejected_not_split_per_character():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["tools"] = "docker"
|
||||
with pytest.raises(BundlerError, match="'requires.tools' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_string_mcp_rejected_not_split_per_character():
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["mcp"] = "github"
|
||||
with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
@@ -0,0 +1 @@
|
||||
"""Extensions test package."""
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for the bundled ``bug`` extension.
|
||||
|
||||
Validates:
|
||||
- Bundled layout (manifest, README, three command files)
|
||||
- Catalog registration
|
||||
- Wheel/source-checkout resolution via ``_locate_bundled_extension``
|
||||
- Install via ``ExtensionManager.install_from_directory`` copies the three
|
||||
command files and records them in the installed manifest (command
|
||||
registration with AI agents is exercised separately and not asserted here)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli import _locate_bundled_extension
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "bug"
|
||||
|
||||
EXPECTED_COMMANDS = {
|
||||
"speckit.bug.assess",
|
||||
"speckit.bug.fix",
|
||||
"speckit.bug.test",
|
||||
}
|
||||
|
||||
|
||||
# ── Bundled extension layout ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionLayout:
|
||||
def test_extension_yml_exists(self):
|
||||
assert (EXT_DIR / "extension.yml").is_file()
|
||||
|
||||
def test_extension_yml_has_required_fields(self):
|
||||
manifest = yaml.safe_load(
|
||||
(EXT_DIR / "extension.yml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert manifest["extension"]["id"] == "bug"
|
||||
assert manifest["extension"]["name"] == "Bug Triage Workflow"
|
||||
assert manifest["extension"]["author"] == "spec-kit-core"
|
||||
commands = {c["name"] for c in manifest["provides"]["commands"]}
|
||||
assert commands == EXPECTED_COMMANDS
|
||||
|
||||
def test_readme_exists(self):
|
||||
readme = EXT_DIR / "README.md"
|
||||
assert readme.is_file()
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
assert "Bug Triage Workflow Extension" in text
|
||||
|
||||
def test_command_files_exist(self):
|
||||
for name in EXPECTED_COMMANDS:
|
||||
cmd = EXT_DIR / "commands" / f"{name}.md"
|
||||
assert cmd.is_file(), f"Missing command file: {cmd}"
|
||||
|
||||
|
||||
# ── Catalog registration ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCatalogEntry:
|
||||
def test_catalog_lists_bug_as_bundled(self):
|
||||
catalog = json.loads(
|
||||
(PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8")
|
||||
)
|
||||
entry = catalog["extensions"]["bug"]
|
||||
assert entry["bundled"] is True
|
||||
assert entry["id"] == "bug"
|
||||
assert entry["author"] == "spec-kit-core"
|
||||
|
||||
|
||||
# ── Bundle resolution ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBundleResolution:
|
||||
def test_locate_bundled_extension_finds_bug(self):
|
||||
located = _locate_bundled_extension("bug")
|
||||
assert located is not None
|
||||
assert (located / "extension.yml").is_file()
|
||||
|
||||
|
||||
# ── Install ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionInstall:
|
||||
def test_install_from_directory(self, tmp_path: Path):
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
(tmp_path / ".specify").mkdir()
|
||||
manager = ExtensionManager(tmp_path)
|
||||
manifest = manager.install_from_directory(EXT_DIR, "0.9.0", register_commands=False)
|
||||
|
||||
assert manifest.id == "bug"
|
||||
assert manager.registry.is_installed("bug")
|
||||
|
||||
# All three command files are copied into the installed extension dir
|
||||
installed = tmp_path / ".specify" / "extensions" / "bug"
|
||||
for name in EXPECTED_COMMANDS:
|
||||
assert (installed / "commands" / f"{name}.md").is_file()
|
||||
|
||||
def test_install_command_names(self, tmp_path: Path):
|
||||
"""The installed manifest exposes the expected command names."""
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
(tmp_path / ".specify").mkdir()
|
||||
manager = ExtensionManager(tmp_path)
|
||||
manifest = manager.install_from_directory(EXT_DIR, "0.9.0", register_commands=False)
|
||||
|
||||
names = {c["name"] for c in manifest.commands}
|
||||
assert names == EXPECTED_COMMANDS
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the bundled git extension."""
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
"""Static guard: the Specify CLI source must contain no agent-context lifecycle code.
|
||||
|
||||
The ``agent-context`` extension is a full opt-in and owns its own lifecycle. The
|
||||
Python codebase (``src/specify_cli/**``) must therefore not reference any of the
|
||||
removed context-section management helpers, the extension config helpers, the
|
||||
context markers, or the obsolete deprecation message.
|
||||
|
||||
Maps to contract C5 / FR-002 / FR-003 / FR-006 / SC-002 / SC-003.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC_ROOT = PROJECT_ROOT / "src" / "specify_cli"
|
||||
|
||||
FORBIDDEN_SYMBOLS = [
|
||||
"upsert_context_section",
|
||||
"remove_context_section",
|
||||
"_agent_context_extension_enabled",
|
||||
"_resolve_context_markers",
|
||||
"_resolve_context_files",
|
||||
"_resolve_context_file_values",
|
||||
"_build_context_section",
|
||||
"_AGENT_CTX_EXT_CONFIG",
|
||||
"_load_agent_context_config",
|
||||
"_save_agent_context_config",
|
||||
"_update_agent_context_config_file",
|
||||
"CONTEXT_MARKER_START",
|
||||
"CONTEXT_MARKER_END",
|
||||
"agent-context-config",
|
||||
"agent_context_config",
|
||||
"__CONTEXT_FILE__",
|
||||
"_context_file_display",
|
||||
"Inline agent-context updates",
|
||||
"v0.12.0",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cli_source_texts() -> list[tuple[str, str]]:
|
||||
"""Read every CLI source file once, shared across all parametrized cases."""
|
||||
return [
|
||||
(str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8"))
|
||||
for path in SRC_ROOT.rglob("*.py")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS)
|
||||
def test_symbol_absent_from_cli_source(symbol, cli_source_texts):
|
||||
offenders = [rel for rel, text in cli_source_texts if symbol in text]
|
||||
assert not offenders, (
|
||||
f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}"
|
||||
)
|
||||
@@ -0,0 +1,989 @@
|
||||
"""Tests for the bundled ``agent-context`` extension and related plumbing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli import (
|
||||
save_init_options,
|
||||
)
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
|
||||
BASH = shutil.which("bash")
|
||||
POWERSHELL = (
|
||||
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
)
|
||||
# On Windows, prefer the built-in Windows PowerShell 5.1 (.NET Framework) when a
|
||||
# test needs to exercise a 5.1-specific code path; fall back to whatever
|
||||
# POWERSHELL resolves to elsewhere.
|
||||
WINDOWS_POWERSHELL = (
|
||||
(shutil.which("powershell.exe") or shutil.which("powershell") or POWERSHELL)
|
||||
if os.name == "nt"
|
||||
else POWERSHELL
|
||||
)
|
||||
|
||||
|
||||
def _write_ext_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write a minimal agent-context extension config directly.
|
||||
|
||||
The CLI no longer owns the extension config — the bundled extension does —
|
||||
so tests write it themselves rather than going through any CLI helper.
|
||||
"""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_files": overrides.get("context_files", []),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{
|
||||
"start": "<!-- SPECKIT START -->",
|
||||
"end": "<!-- SPECKIT END -->",
|
||||
},
|
||||
),
|
||||
}
|
||||
path = (
|
||||
project_root
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
yaml.safe_dump(cfg, default_flow_style=False, sort_keys=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
# ── Bundled extension layout ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionLayout:
|
||||
"""The bundled agent-context extension ships a complete package."""
|
||||
|
||||
def test_extension_yml_exists(self):
|
||||
assert (EXT_DIR / "extension.yml").is_file()
|
||||
|
||||
def test_extension_yml_has_required_fields(self):
|
||||
manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text())
|
||||
assert manifest["extension"]["id"] == "agent-context"
|
||||
assert manifest["extension"]["name"] == "Coding Agent Context"
|
||||
assert manifest["extension"]["author"] == "spec-kit-core"
|
||||
# Provides at least the manual update command
|
||||
commands = {c["name"] for c in manifest["provides"]["commands"]}
|
||||
assert "speckit.agent-context.update" in commands
|
||||
|
||||
def test_readme_exists(self):
|
||||
readme = EXT_DIR / "README.md"
|
||||
assert readme.is_file()
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
assert "Coding Agent Context Extension" in text
|
||||
|
||||
def test_config_template_exists(self):
|
||||
cfg = EXT_DIR / "agent-context-config.yml"
|
||||
assert cfg.is_file()
|
||||
parsed = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
||||
assert "context_file" in parsed
|
||||
assert "context_markers" in parsed
|
||||
|
||||
def test_command_file_exists(self):
|
||||
cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md"
|
||||
assert cmd.is_file()
|
||||
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
|
||||
|
||||
def test_command_file_documents_context_file_constraints(self):
|
||||
text = (
|
||||
EXT_DIR / "commands" / "speckit.agent-context.update.md"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "context file(s)" in text
|
||||
assert "Windows drive paths" in text
|
||||
assert "backslash separators" in text
|
||||
|
||||
def test_bundled_scripts_exist(self):
|
||||
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
|
||||
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
|
||||
|
||||
def test_bash_script_reads_extension_config(self):
|
||||
text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# The script must consult the extension config, not init-options.json
|
||||
assert "agent-context-config.yml" in text
|
||||
assert "context_file" in text
|
||||
assert "context_markers" in text
|
||||
|
||||
|
||||
# ── Catalog registration ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCatalogEntry:
|
||||
def test_catalog_lists_agent_context_as_bundled(self):
|
||||
catalog = json.loads(
|
||||
(PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8")
|
||||
)
|
||||
entry = catalog["extensions"]["agent-context"]
|
||||
assert entry["bundled"] is True
|
||||
assert entry["id"] == "agent-context"
|
||||
assert entry["author"] == "spec-kit-core"
|
||||
|
||||
|
||||
|
||||
|
||||
def _install_agent_context_config(project_root: Path, **overrides: object) -> None:
|
||||
_write_ext_config(project_root, **overrides)
|
||||
# Mirror the real install layout: the extension ships its own
|
||||
# agent->context-file defaults map alongside the config. Self-seeding
|
||||
# tests depend on it, so require it to exist and always copy it rather
|
||||
# than silently skipping when it is missing.
|
||||
defaults_src = EXT_DIR / "agent-context-defaults.json"
|
||||
assert defaults_src.is_file(), (
|
||||
f"bundled agent-context defaults map missing: {defaults_src}"
|
||||
)
|
||||
defaults_dst = (
|
||||
project_root
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-defaults.json"
|
||||
)
|
||||
defaults_dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(defaults_src, defaults_dst)
|
||||
|
||||
|
||||
def _bash_posix_path(path: Path) -> str:
|
||||
"""Convert a Windows path to the POSIX form used by the available bash."""
|
||||
resolved = str(path.resolve())
|
||||
if os.name != "nt":
|
||||
return resolved
|
||||
|
||||
if BASH:
|
||||
converted = subprocess.run(
|
||||
[
|
||||
BASH,
|
||||
"-lc",
|
||||
"command -v cygpath >/dev/null 2>&1 && cygpath -u \"$1\"",
|
||||
"bash",
|
||||
resolved,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if converted.returncode == 0 and converted.stdout.strip():
|
||||
return converted.stdout.strip()
|
||||
|
||||
drive = path.drive.rstrip(":").lower()
|
||||
posix = path.as_posix()
|
||||
return f"/mnt/{drive}{posix[2:]}" if drive else posix
|
||||
|
||||
|
||||
def _ensure_test_python_on_path(project_root: Path) -> Path:
|
||||
"""Create python/python3 shims that run the current pytest interpreter."""
|
||||
shim_dir = project_root / ".test-python-bin"
|
||||
shim_dir.mkdir(exist_ok=True)
|
||||
python_exe = Path(sys.executable).resolve()
|
||||
shell_python = _bash_posix_path(python_exe)
|
||||
|
||||
for name in ("python", "python3"):
|
||||
shell_shim = shim_dir / name
|
||||
shell_shim.write_text(
|
||||
f"#!/usr/bin/env sh\nexec {shlex_quote(shell_python)} \"$@\"\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
shell_shim.chmod(0o755)
|
||||
|
||||
if os.name == "nt":
|
||||
cmd_shim = shim_dir / f"{name}.cmd"
|
||||
cmd_shim.write_text(
|
||||
f'@echo off\r\n"{python_exe}" %*\r\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return shim_dir
|
||||
|
||||
|
||||
def _current_pythonpath() -> str:
|
||||
"""Return sys.path entries needed by child script interpreters."""
|
||||
entries = [
|
||||
entry
|
||||
for entry in sys.path
|
||||
if isinstance(entry, str) and entry
|
||||
]
|
||||
existing = os.environ.get("PYTHONPATH")
|
||||
if existing:
|
||||
entries.extend(entry for entry in existing.split(os.pathsep) if entry)
|
||||
return os.pathsep.join(dict.fromkeys(entries))
|
||||
|
||||
|
||||
def _bundled_script_env(
|
||||
project_root: Path,
|
||||
*,
|
||||
for_bash: bool = False,
|
||||
speckit_python: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
shim_dir = _ensure_test_python_on_path(project_root)
|
||||
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
|
||||
env["SPECKIT_PYTHON"] = (
|
||||
speckit_python
|
||||
if speckit_python is not None
|
||||
else (_bash_posix_path(Path(sys.executable)) if for_bash else sys.executable)
|
||||
)
|
||||
pythonpath = _current_pythonpath()
|
||||
if pythonpath:
|
||||
env["PYTHONPATH"] = pythonpath
|
||||
return env
|
||||
|
||||
|
||||
def _run_bash_agent_context_script(
|
||||
project_root: Path,
|
||||
*,
|
||||
speckit_python: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
|
||||
env = _bundled_script_env(
|
||||
project_root,
|
||||
for_bash=True,
|
||||
speckit_python=speckit_python,
|
||||
)
|
||||
if os.name == "nt":
|
||||
root = _bash_posix_path(project_root)
|
||||
script_path = _bash_posix_path(script)
|
||||
shim_dir = _bash_posix_path(_ensure_test_python_on_path(project_root))
|
||||
command = (
|
||||
f"export PATH={shlex_quote(shim_dir)}:\"$PATH\"; "
|
||||
f"cd {shlex_quote(root)} && {shlex_quote(script_path)}"
|
||||
)
|
||||
return subprocess.run(
|
||||
[BASH, "-lc", command],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return subprocess.run(
|
||||
[BASH, str(script)],
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def shlex_quote(value: str) -> str:
|
||||
return "'" + value.replace("'", "'\"'\"'") + "'"
|
||||
|
||||
|
||||
def _run_powershell_agent_context_script(
|
||||
project_root: Path, powershell: str | None = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
env = _bundled_script_env(project_root)
|
||||
return subprocess.run(
|
||||
[
|
||||
powershell or POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script),
|
||||
],
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def _run_powershell_agent_context_script_with_env(
|
||||
project_root: Path,
|
||||
*,
|
||||
speckit_python: str,
|
||||
) -> subprocess.CompletedProcess:
|
||||
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
env = _bundled_script_env(project_root, speckit_python=speckit_python)
|
||||
return subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(script),
|
||||
],
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
class TestBundledUpdaterPathValidation:
|
||||
def test_bundled_script_env_makes_yaml_importable(self, tmp_path):
|
||||
env = _bundled_script_env(tmp_path)
|
||||
|
||||
result = subprocess.run(
|
||||
[env["SPECKIT_PYTHON"], "-c", "import yaml"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_trims_context_file_fallback(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file=" AGENTS.md ",
|
||||
context_files=[],
|
||||
)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
|
||||
assert (project / "AGENTS.md").exists()
|
||||
assert not (project / " AGENTS.md ").exists()
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_rejects_symlink_escape(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["link/out.md"],
|
||||
)
|
||||
|
||||
if os.name == "nt":
|
||||
root = _bash_posix_path(tmp_path)
|
||||
create_link = subprocess.run(
|
||||
[
|
||||
BASH,
|
||||
"-lc",
|
||||
f"ln -s {shlex_quote(root + '/outside')} "
|
||||
f"{shlex_quote(root + '/project/link')}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if create_link.returncode != 0:
|
||||
pytest.skip(f"symlink unavailable: {create_link.stderr}")
|
||||
else:
|
||||
try:
|
||||
(project / "link").symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlink unavailable: {exc}")
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "resolves outside the project root" in result.stderr
|
||||
assert not (outside / "out.md").exists()
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_deduplicates_context_files_in_order(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
|
||||
)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
output = result.stderr + result.stdout
|
||||
assert output.count("agent-context: updated AGENTS.md") == 1
|
||||
assert output.count("agent-context: updated CLAUDE.md") == 1
|
||||
assert "agent-context: updated agents.md" not in output
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_discovers_nested_plan(self, tmp_path):
|
||||
"""Plan discovery recurses into scoped layouts (#3024)."""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=[],
|
||||
)
|
||||
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
|
||||
plan.parent.mkdir(parents=True)
|
||||
plan.write_text("# Plan\n", encoding="utf-8")
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
# The old one-level glob (specs/*/plan.md) would find nothing here, so no
|
||||
# "at" line would be emitted. Normalize separators before matching: on
|
||||
# MSYS bash the emitted path may be absolute with backslashes.
|
||||
assert "specs/scope/001-feature/plan.md" in text.replace("\\", "/")
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
|
||||
result = _run_bash_agent_context_script(
|
||||
project,
|
||||
speckit_python="/definitely/missing/python",
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
|
||||
assert (project / "AGENTS.md").exists()
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_rejects_backslash_context_files(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["nested\\AGENTS.md"],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "must not contain backslash separators" in (
|
||||
result.stderr + result.stdout
|
||||
)
|
||||
assert not (project / "nested" / "AGENTS.md").exists()
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_rejects_drive_qualified_context_files(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["C:tmp/outside.md"],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "must be project-relative paths" in (result.stderr + result.stdout)
|
||||
assert not (project / "tmp" / "outside.md").exists()
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
output = result.stderr + result.stdout
|
||||
assert output.count("agent-context: updated AGENTS.md") == 1
|
||||
assert output.count("agent-context: updated CLAUDE.md") == 1
|
||||
assert "agent-context: updated agents.md" not in output
|
||||
|
||||
@pytest.mark.skipif(WINDOWS_POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_discovers_nested_plan(self, tmp_path):
|
||||
"""Plan discovery recurses into scoped layouts (#3024).
|
||||
|
||||
The relative-path fix this covers is specific to Windows PowerShell 5.1
|
||||
(.NET Framework), so prefer ``powershell.exe`` over ``pwsh`` here to
|
||||
actually exercise that failure mode on Windows.
|
||||
"""
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=[],
|
||||
)
|
||||
plan = project / "specs" / "scope" / "001-feature" / "plan.md"
|
||||
plan.parent.mkdir(parents=True)
|
||||
plan.write_text("# Plan\n", encoding="utf-8")
|
||||
|
||||
result = _run_powershell_agent_context_script(
|
||||
project, powershell=WINDOWS_POWERSHELL
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "at specs/scope/001-feature/plan.md" in text
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script_with_env(
|
||||
project,
|
||||
speckit_python=str(project / "missing-python"),
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
|
||||
assert (project / "AGENTS.md").exists()
|
||||
|
||||
@pytest.mark.skipif(
|
||||
POWERSHELL is None or os.name != "nt",
|
||||
reason="Windows PowerShell junction test requires Windows",
|
||||
)
|
||||
def test_powershell_script_rejects_junction_escape(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
outside = tmp_path / "outside"
|
||||
project.mkdir()
|
||||
outside.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["link/out.md"],
|
||||
)
|
||||
|
||||
create_link = subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
(
|
||||
"New-Item -ItemType Junction "
|
||||
f"-Path {str(project / 'link')!r} "
|
||||
f"-Target {str(outside)!r} | Out-Null"
|
||||
),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if create_link.returncode != 0:
|
||||
pytest.skip(f"junction unavailable: {create_link.stderr}")
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "resolves outside the project root" in (result.stderr + result.stdout)
|
||||
assert not (outside / "out.md").exists()
|
||||
|
||||
|
||||
# ── CLI does not resolve agent context placeholders ──────────────────────────
|
||||
|
||||
|
||||
class TestSkillPlaceholderContextResolution:
|
||||
"""The CLI no longer resolves any ``__CONTEXT_FILE__`` placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the CLI neither reads integration metadata nor the
|
||||
extension config when rendering commands/skills.
|
||||
"""
|
||||
|
||||
def test_cli_does_not_resolve_context_placeholder(self, tmp_path):
|
||||
content = CommandRegistrar.resolve_skill_placeholders(
|
||||
"codex",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
assert content == "Read __CONTEXT_FILE__"
|
||||
|
||||
def test_extension_config_does_not_influence_resolution(self, tmp_path):
|
||||
# Even a populated extension config must not influence resolution.
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="FROM_CONFIG.md",
|
||||
context_files=["ALSO_CONFIG.md"],
|
||||
)
|
||||
|
||||
content = CommandRegistrar.resolve_skill_placeholders(
|
||||
"claude",
|
||||
{},
|
||||
"Read __CONTEXT_FILE__",
|
||||
tmp_path,
|
||||
)
|
||||
assert "FROM_CONFIG.md" not in content
|
||||
assert "ALSO_CONFIG.md" not in content
|
||||
assert content == "Read __CONTEXT_FILE__"
|
||||
|
||||
|
||||
# ── CLI no longer owns the agent-context extension config ────────────────────
|
||||
|
||||
|
||||
class TestCliDoesNotManageExtensionConfig:
|
||||
"""The Python codebase must not read or write the extension config."""
|
||||
|
||||
def test_config_helpers_are_removed(self):
|
||||
import specify_cli
|
||||
|
||||
for name in (
|
||||
"_load_agent_context_config",
|
||||
"_save_agent_context_config",
|
||||
"_update_agent_context_config_file",
|
||||
"_AGENT_CTX_EXT_CONFIG",
|
||||
):
|
||||
assert not hasattr(specify_cli, name), name
|
||||
|
||||
def test_no_agent_context_config_symbols_in_source(self):
|
||||
src = PROJECT_ROOT / "src" / "specify_cli"
|
||||
offenders = []
|
||||
for path in src.rglob("*.py"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if "agent-context-config" in text or "agent_context_config" in text:
|
||||
offenders.append(str(path.relative_to(PROJECT_ROOT)))
|
||||
assert not offenders, offenders
|
||||
|
||||
def test_update_init_options_does_not_create_ext_config(self, tmp_path):
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||||
from specify_cli.integrations._helpers import (
|
||||
_update_init_options_for_integration,
|
||||
)
|
||||
|
||||
_update_init_options_for_integration(
|
||||
tmp_path, INTEGRATION_REGISTRY["claude"], script_type="sh"
|
||||
)
|
||||
|
||||
cfg = (
|
||||
tmp_path
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
assert not cfg.exists()
|
||||
|
||||
def test_clear_init_options_does_not_create_ext_config(self, tmp_path):
|
||||
from specify_cli.integrations._helpers import (
|
||||
_clear_init_options_for_integration,
|
||||
)
|
||||
|
||||
save_init_options(tmp_path, {"integration": "claude", "ai": "claude"})
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
|
||||
cfg = (
|
||||
tmp_path
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
assert not cfg.exists()
|
||||
|
||||
|
||||
# ── Extension self-seeds its target from the active integration ──────────────
|
||||
|
||||
|
||||
class TestExtensionSelfSeed:
|
||||
"""When its own config declares no target, the bundled extension derives
|
||||
the context file from the active integration using its OWN bundled
|
||||
agent->context-file defaults map (no Specify CLI dependency)."""
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_self_seeds_from_active_integration(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
# Config present but empty — no context_file / context_files.
|
||||
_install_agent_context_config(project, context_file="", context_files=[])
|
||||
# Active integration recorded in init-options.json (codex -> AGENTS.md).
|
||||
save_init_options(project, {"integration": "codex", "ai": "codex"})
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
|
||||
assert (project / "AGENTS.md").exists()
|
||||
assert "<!-- SPECKIT START -->" in (
|
||||
project / "AGENTS.md"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_nothing_to_do_without_integration(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file="", context_files=[])
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
assert "nothing to do" in (result.stderr + result.stdout)
|
||||
|
||||
|
||||
_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
|
||||
|
||||
|
||||
class TestPlanDiscovery:
|
||||
"""Mtime fallback must find plans in nested spec layouts (#3024).
|
||||
|
||||
Repos using SPECIFY_FEATURE_DIRECTORY place plans at
|
||||
``specs/<scope>/<feature>/plan.md``; a one-level ``specs/*/plan.md``
|
||||
glob never matches those.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _make_plans(project: Path) -> Path:
|
||||
# Older flat plan plus a newer nested plan: recursive discovery
|
||||
# must pick the nested one by mtime.
|
||||
flat = project / "specs" / "old-feature" / "plan.md"
|
||||
flat.parent.mkdir(parents=True)
|
||||
flat.write_text("flat plan\n", encoding="utf-8")
|
||||
os.utime(flat, (1_000_000_000, 1_000_000_000))
|
||||
nested = project / "specs" / "scope" / "new-feature" / "plan.md"
|
||||
nested.parent.mkdir(parents=True)
|
||||
nested.write_text("nested plan\n", encoding="utf-8")
|
||||
return nested
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_finds_nested_plan(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
self._make_plans(project)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
content = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "specs/scope/new-feature/plan.md" in content
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_finds_nested_plan(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(
|
||||
project,
|
||||
context_file="AGENTS.md",
|
||||
context_files=["AGENTS.md"],
|
||||
)
|
||||
self._make_plans(project)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
content = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "specs/scope/new-feature/plan.md" in content
|
||||
|
||||
|
||||
class TestMdcFrontmatter:
|
||||
"""Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter
|
||||
so the rule file is auto-loaded; non-``.mdc`` targets must not gain any."""
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_prepends_mdc_frontmatter(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8")
|
||||
assert text.startswith("---\nalwaysApply: true\n---\n")
|
||||
assert "<!-- SPECKIT START -->" in text
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_mdc_frontmatter_is_idempotent(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
|
||||
|
||||
_run_bash_agent_context_script(project)
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8")
|
||||
assert text.count("alwaysApply: true") == 1
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_repairs_existing_mdc_frontmatter(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
|
||||
target = project / _MDC_CONTEXT_FILE
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
"---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = target.read_text(encoding="utf-8")
|
||||
assert "alwaysApply: true" in text
|
||||
assert "alwaysApply: false" not in text
|
||||
assert "description: My rules" in text
|
||||
assert "User notes" in text
|
||||
|
||||
@requires_bash
|
||||
def test_bash_script_skips_frontmatter_for_non_mdc(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file="AGENTS.md")
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "alwaysApply" not in text
|
||||
assert text.startswith("<!-- SPECKIT START -->")
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_prepends_mdc_frontmatter(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8")
|
||||
assert text.startswith("---\nalwaysApply: true\n---\n")
|
||||
assert "<!-- SPECKIT START -->" in text
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_repairs_existing_mdc_frontmatter(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE)
|
||||
target = project / _MDC_CONTEXT_FILE
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
"---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = target.read_text(encoding="utf-8")
|
||||
assert "alwaysApply: true" in text
|
||||
assert "alwaysApply: false" not in text
|
||||
assert "description: My rules" in text
|
||||
assert "User notes" in text
|
||||
|
||||
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
|
||||
def test_powershell_script_skips_frontmatter_for_non_mdc(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_install_agent_context_config(project, context_file="AGENTS.md")
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
text = (project / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "alwaysApply" not in text
|
||||
assert text.startswith("<!-- SPECKIT START -->")
|
||||
|
||||
|
||||
_LEGACY_CONTEXT = (
|
||||
"# CLAUDE.md\n\n"
|
||||
"Some user notes.\n\n"
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"Legacy managed section written by an older Spec Kit version.\n"
|
||||
"<!-- SPECKIT END -->\n\n"
|
||||
"More user notes.\n"
|
||||
)
|
||||
|
||||
|
||||
class TestBackwardCompatibility:
|
||||
"""Legacy projects must keep working; the CLI never touches their artifacts."""
|
||||
|
||||
def _seed_legacy_project(self, project_root: Path) -> Path:
|
||||
ctx = project_root / "CLAUDE.md"
|
||||
ctx.write_text(_LEGACY_CONTEXT, encoding="utf-8")
|
||||
_write_ext_config(project_root, context_file="CLAUDE.md")
|
||||
save_init_options(project_root, {"integration": "claude", "ai": "claude"})
|
||||
return ctx
|
||||
|
||||
def test_integration_setup_leaves_legacy_artifacts_untouched(self, tmp_path):
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
project = tmp_path / "legacy"
|
||||
project.mkdir()
|
||||
ctx = self._seed_legacy_project(project)
|
||||
cfg_path = (
|
||||
project / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
before_ctx = ctx.read_text(encoding="utf-8")
|
||||
before_cfg = cfg_path.read_text(encoding="utf-8")
|
||||
|
||||
integration = INTEGRATION_REGISTRY["claude"]
|
||||
m = IntegrationManifest("claude", project)
|
||||
integration.setup(project, m)
|
||||
|
||||
assert ctx.read_text(encoding="utf-8") == before_ctx
|
||||
assert cfg_path.read_text(encoding="utf-8") == before_cfg
|
||||
|
||||
def test_integration_switch_and_uninstall_leave_legacy_artifacts_untouched(
|
||||
self, tmp_path
|
||||
):
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY
|
||||
from specify_cli.integrations._helpers import (
|
||||
_clear_init_options_for_integration,
|
||||
_update_init_options_for_integration,
|
||||
)
|
||||
|
||||
project = tmp_path / "legacy"
|
||||
project.mkdir()
|
||||
ctx = self._seed_legacy_project(project)
|
||||
cfg_path = (
|
||||
project / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
before_ctx = ctx.read_text(encoding="utf-8")
|
||||
before_cfg = cfg_path.read_text(encoding="utf-8")
|
||||
|
||||
# Switch to a different integration.
|
||||
_update_init_options_for_integration(
|
||||
project, INTEGRATION_REGISTRY["gemini"], script_type="sh"
|
||||
)
|
||||
assert ctx.read_text(encoding="utf-8") == before_ctx
|
||||
assert cfg_path.read_text(encoding="utf-8") == before_cfg
|
||||
|
||||
# Uninstall.
|
||||
_clear_init_options_for_integration(project, "gemini")
|
||||
assert ctx.read_text(encoding="utf-8") == before_ctx
|
||||
assert cfg_path.read_text(encoding="utf-8") == before_cfg
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Tests that update-agent-context.sh/.ps1 prefer feature.json over mtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
from tests.extensions.test_extension_agent_context import (
|
||||
BASH,
|
||||
POWERSHELL,
|
||||
_bash_posix_path,
|
||||
_run_bash_agent_context_script,
|
||||
_run_powershell_agent_context_script,
|
||||
)
|
||||
|
||||
|
||||
def _setup_project(root: Path, context_file: str = "CLAUDE.md") -> None:
|
||||
"""Write agent-context extension config as JSON.
|
||||
|
||||
JSON is valid YAML so bash+PyYAML can parse it, and PowerShell's built-in
|
||||
ConvertFrom-Json can parse it without needing powershell-yaml or Python.
|
||||
Written directly as JSON (not via yaml.safe_dump) so the PS ConvertFrom-Json
|
||||
fallback actually works on Windows CI.
|
||||
"""
|
||||
cfg_dir = root / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
json.dumps({
|
||||
"context_file": context_file,
|
||||
"context_markers": {
|
||||
"start": "<!-- SPECKIT START -->",
|
||||
"end": "<!-- SPECKIT END -->",
|
||||
},
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _write_feature_json(root: Path, feature_directory: str) -> None:
|
||||
specify_dir = root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _make_plan(root: Path, feature_dir: str, content: str = "# plan\n") -> Path:
|
||||
p = root / feature_dir / "plan.md"
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
|
||||
"""feature.json points to the active feature; that plan.md is injected."""
|
||||
_setup_project(tmp_path)
|
||||
_make_plan(tmp_path, "specs/001-active")
|
||||
_write_feature_json(tmp_path, "specs/001-active")
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
|
||||
"""An older spec's plan.md modified more recently must NOT win over feature.json."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
_write_feature_json(tmp_path, "specs/001-active")
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_falls_back_to_mtime_when_feature_json_absent(tmp_path: Path) -> None:
|
||||
"""No feature.json → mtime fallback selects the most recently modified plan."""
|
||||
_setup_project(tmp_path)
|
||||
old = _make_plan(tmp_path, "specs/000-old")
|
||||
newer = _make_plan(tmp_path, "specs/001-newer")
|
||||
now = time.time()
|
||||
os.utime(old, (now - 10, now - 10))
|
||||
os.utime(newer, (now, now))
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-newer/plan.md" in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_falls_back_to_mtime_when_plan_not_yet_created(tmp_path: Path) -> None:
|
||||
"""feature.json exists but plan.md not yet written → fall back to mtime."""
|
||||
_setup_project(tmp_path)
|
||||
_make_plan(tmp_path, "specs/000-old")
|
||||
_write_feature_json(tmp_path, "specs/001-new")
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/000-old/plan.md" in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_absolute_feature_dir_under_project_root(tmp_path: Path) -> None:
|
||||
"""Absolute feature_directory under PROJECT_ROOT → project-relative path in context."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
# Write POSIX absolute path — mtime would pick 000-stale without feature.json
|
||||
_write_feature_json(tmp_path, _bash_posix_path(tmp_path / "specs" / "001-active"))
|
||||
|
||||
result = _run_bash_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
assert _bash_posix_path(tmp_path) not in ctx
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_bash_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
|
||||
"""Absolute feature_directory outside PROJECT_ROOT → absolute path preserved in context."""
|
||||
project = tmp_path / "project"
|
||||
external = tmp_path / "external" / "001-feature"
|
||||
project.mkdir()
|
||||
external.mkdir(parents=True)
|
||||
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
_setup_project(project)
|
||||
_write_feature_json(project, _bash_posix_path(external))
|
||||
|
||||
result = _run_bash_agent_context_script(project)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert _bash_posix_path(external) + "/plan.md" in ctx
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_ps_uses_feature_json_when_plan_exists(tmp_path: Path) -> None:
|
||||
"""PowerShell: absolute feature_directory under project root is normalized to relative path."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
# Native str() — PowerShell expects Windows-native paths, not MSYS2 /c/... form
|
||||
_write_feature_json(tmp_path, str(tmp_path / "specs" / "001-active"))
|
||||
|
||||
result = _run_powershell_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "at specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
assert tmp_path.resolve().as_posix() not in ctx
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_ps_ignores_newer_stale_plan_when_feature_json_present(tmp_path: Path) -> None:
|
||||
"""PowerShell: stale plan touched more recently must not win over feature.json."""
|
||||
_setup_project(tmp_path)
|
||||
active = _make_plan(tmp_path, "specs/001-active")
|
||||
stale = _make_plan(tmp_path, "specs/000-stale")
|
||||
now = time.time()
|
||||
os.utime(active, (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
_write_feature_json(tmp_path, "specs/001-active")
|
||||
|
||||
result = _run_powershell_agent_context_script(tmp_path)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "specs/001-active/plan.md" in ctx
|
||||
assert "specs/000-stale/plan.md" not in ctx
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_ps_absolute_feature_dir_outside_project_root(tmp_path: Path) -> None:
|
||||
"""PowerShell: absolute feature_directory outside project root → absolute path preserved."""
|
||||
project = tmp_path / "project"
|
||||
external = tmp_path / "external" / "001-feature"
|
||||
project.mkdir()
|
||||
external.mkdir(parents=True)
|
||||
(external / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
_setup_project(project)
|
||||
_write_feature_json(project, str(external))
|
||||
|
||||
result = _run_powershell_agent_context_script(project)
|
||||
assert result.returncode == 0, result.stderr + result.stdout
|
||||
ctx = (project / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert external.resolve().as_posix() + "/plan.md" in ctx
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Parity tests: update_agent_context.py vs update-agent-context.sh/.ps1.
|
||||
|
||||
Each test prepares two identical project trees, runs the bash script in one
|
||||
and the Python port in the other, then compares exit codes, output (with
|
||||
project roots normalized) and the resulting context-file bytes. PowerShell
|
||||
tests compare the resulting file content only and are skipped when ``pwsh``
|
||||
is unavailable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.extensions.test_extension_agent_context import (
|
||||
BASH,
|
||||
EXT_DIR,
|
||||
POWERSHELL,
|
||||
_bundled_script_env,
|
||||
)
|
||||
|
||||
PY_SCRIPT = EXT_DIR / "scripts" / "python" / "update_agent_context.py"
|
||||
BASH_SCRIPT = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
|
||||
PS_SCRIPT = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
|
||||
|
||||
requires_posix_bash = pytest.mark.skipif(
|
||||
not BASH or os.name == "nt",
|
||||
reason="POSIX bash required for side-by-side parity runs",
|
||||
)
|
||||
|
||||
|
||||
def run_bash(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[BASH, str(BASH_SCRIPT), *args],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root, for_bash=True),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def run_python(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, str(PY_SCRIPT), *args],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def run_powershell(project_root: Path, *args: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[
|
||||
POWERSHELL,
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-File",
|
||||
str(PS_SCRIPT),
|
||||
*args,
|
||||
],
|
||||
cwd=project_root,
|
||||
env=_bundled_script_env(project_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
|
||||
def normalize(text: str, project_root: Path) -> str:
|
||||
return text.replace(str(project_root.resolve()), "__ROOT__").replace(
|
||||
str(project_root), "__ROOT__"
|
||||
)
|
||||
|
||||
|
||||
def write_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write the extension config as JSON (valid YAML, PS-parseable too)."""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_files": overrides.get("context_files", []),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{"start": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT END -->"},
|
||||
),
|
||||
}
|
||||
cfg_dir = project_root / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True, exist_ok=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
json.dumps(cfg), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def make_project(root: Path, **config: object) -> Path:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
write_config(root, **config)
|
||||
return root
|
||||
|
||||
|
||||
def add_plan(project_root: Path, feature_dir: str = "specs/001-demo") -> None:
|
||||
plan = project_root / feature_dir / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
(project_root / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
(project_root / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_dir}), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def twin_projects(tmp_path: Path, **config: object) -> tuple[Path, Path]:
|
||||
return (
|
||||
make_project(tmp_path / "proj-a", **config),
|
||||
make_project(tmp_path / "proj-b", **config),
|
||||
)
|
||||
|
||||
|
||||
def assert_parity(
|
||||
bash: subprocess.CompletedProcess,
|
||||
py: subprocess.CompletedProcess,
|
||||
repo_a: Path,
|
||||
repo_b: Path,
|
||||
) -> None:
|
||||
assert py.returncode == bash.returncode, py.stderr + bash.stderr
|
||||
assert normalize(py.stdout, repo_b) == normalize(bash.stdout, repo_a)
|
||||
assert normalize(py.stderr, repo_b) == normalize(bash.stderr, repo_a)
|
||||
|
||||
|
||||
# ── Fresh file and upsert behavior ───────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_creates_fresh_context_file_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content_a = (repo_a / "AGENTS.md").read_bytes()
|
||||
content_b = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content_a == content_b
|
||||
assert b"at specs/001-demo/plan.md" in content_b
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_replaces_existing_section_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
existing = (
|
||||
"# My project\n\n"
|
||||
"<!-- SPECKIT START -->\nstale section\n<!-- SPECKIT END -->\n"
|
||||
"\nTrailing prose stays.\n"
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "stale section" not in content
|
||||
assert content.startswith("# My project\n")
|
||||
assert "Trailing prose stays." in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@pytest.mark.parametrize(
|
||||
"existing",
|
||||
[
|
||||
"# Doc\n<!-- SPECKIT START -->\ndangling start\n",
|
||||
"dangling end\n<!-- SPECKIT END -->\nrest\n",
|
||||
"no markers at all",
|
||||
],
|
||||
ids=["start-only", "end-only", "no-markers-no-newline"],
|
||||
)
|
||||
def test_python_handles_partial_markers_matching_bash(
|
||||
tmp_path: Path, existing: str
|
||||
) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
|
||||
markers = {"start": "<!-- CTX BEGIN -->", "end": "<!-- CTX FINISH -->"}
|
||||
repo_a, repo_b = twin_projects(
|
||||
tmp_path, context_file="AGENTS.md", context_markers=markers
|
||||
)
|
||||
existing = "intro\n<!-- CTX BEGIN -->\nold\n<!-- CTX FINISH -->\noutro\n"
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert content == (repo_a / "AGENTS.md").read_text(encoding="utf-8")
|
||||
assert "<!-- CTX BEGIN -->" in content
|
||||
assert "old" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
|
||||
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_files=files)
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert bash.stdout.count("agent-context: updated") == 2
|
||||
for name in ("AGENTS.md", "docs/CONTEXT.md"):
|
||||
assert (repo_a / name).read_bytes() == (repo_b / name).read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_normalizes_crlf_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
existing = b"# Doc\r\n\r\n<!-- SPECKIT START -->\r\nold\r\n<!-- SPECKIT END -->\r\ntail\r\n"
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_bytes(existing)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\r" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mdc_frontmatter_repair_matching_bash(tmp_path: Path) -> None:
|
||||
mdc = ".cursor/rules/specify-rules.mdc"
|
||||
cases = {
|
||||
"missing": "# Rules\n",
|
||||
"false-value": "---\ndescription: rules\nalwaysApply: false\n---\n\n# Rules\n",
|
||||
"no-key": "---\ndescription: rules\n---\n\n# Rules\n",
|
||||
}
|
||||
for name, existing in cases.items():
|
||||
repo_a = make_project(tmp_path / f"a-{name}", context_file=mdc)
|
||||
repo_b = make_project(tmp_path / f"b-{name}", context_file=mdc)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
target = repo / mdc
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(existing, encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / mdc).read_text(encoding="utf-8")
|
||||
assert content == (repo_a / mdc).read_text(encoding="utf-8"), name
|
||||
assert "alwaysApply: true" in content, name
|
||||
|
||||
|
||||
# ── Plan-path resolution ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_explicit_plan_argument_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
|
||||
bash = run_bash(repo_a, "specs/009-explicit/plan.md")
|
||||
py = run_python(repo_b, "specs/009-explicit/plan.md")
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/009-explicit/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
now = time.time()
|
||||
for repo in (repo_a, repo_b):
|
||||
for feature, age in (("specs/000-old", 10), ("specs/001-new", 0)):
|
||||
plan = repo / feature / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
os.utime(plan, (now - age, now - age))
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/001-new/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
now = time.time()
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo, "specs/001-active")
|
||||
stale = repo / "specs" / "000-stale" / "plan.md"
|
||||
stale.parent.mkdir(parents=True, exist_ok=True)
|
||||
stale.write_text("# plan\n", encoding="utf-8")
|
||||
os.utime(repo / "specs" / "001-active" / "plan.md", (now - 10, now - 10))
|
||||
os.utime(stale, (now, now))
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/001-active/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_no_plan_omits_at_line_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\nat " not in content
|
||||
|
||||
|
||||
# ── Config gates and path validation ─────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_missing_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a = tmp_path / "proj-a"
|
||||
repo_b = tmp_path / "proj-b"
|
||||
repo_a.mkdir()
|
||||
repo_b.mkdir()
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "not found; nothing to do." in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_unparseable_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a = tmp_path / "proj-a"
|
||||
repo_b = tmp_path / "proj-b"
|
||||
for repo in (repo_a, repo_b):
|
||||
cfg_dir = repo / ".specify" / "extensions" / "agent-context"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
(cfg_dir / "agent-context-config.yml").write_text(
|
||||
"context_file: [unclosed\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "cannot update context." in py.stderr
|
||||
assert "agent-context: skipping update (see above for details)." in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_empty_config_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 0
|
||||
assert "context_files/context_file not set" in py.stderr
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_self_seed_from_init_options_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / ".specify" / "init-options.json").write_text(
|
||||
json.dumps({"integration": "claude"}), encoding="utf-8"
|
||||
)
|
||||
shutil.copy(
|
||||
EXT_DIR / "agent-context-defaults.json",
|
||||
repo
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-defaults.json",
|
||||
)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert (repo_a / "CLAUDE.md").read_bytes() == (repo_b / "CLAUDE.md").read_bytes()
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@pytest.mark.parametrize(
|
||||
"bad_path",
|
||||
["/etc/AGENTS.md", "docs\\AGENTS.md", "../outside.md", "nested/../../escape.md"],
|
||||
ids=["absolute", "backslash", "dotdot", "nested-dotdot"],
|
||||
)
|
||||
def test_python_rejects_escaping_paths_matching_bash(
|
||||
tmp_path: Path, bad_path: str
|
||||
) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file=bad_path)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
assert py.returncode == 1
|
||||
assert not (repo_b / "AGENTS.md").exists()
|
||||
|
||||
|
||||
# ── PowerShell parity (content only) ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
ps = run_powershell(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_upsert_matches_powershell(tmp_path: Path) -> None:
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
|
||||
existing = (
|
||||
"# My project\n\n"
|
||||
"<!-- SPECKIT START -->\nstale\n<!-- SPECKIT END -->\n"
|
||||
"\ntail\n"
|
||||
)
|
||||
for repo in (repo_a, repo_b):
|
||||
add_plan(repo)
|
||||
(repo / "AGENTS.md").write_text(existing, encoding="utf-8")
|
||||
|
||||
ps = run_powershell(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
@@ -0,0 +1,30 @@
|
||||
# Testing Extension Hooks
|
||||
|
||||
This directory contains a mock project to verify that LLM agents correctly identify and execute hook commands defined in `.specify/extensions.yml`.
|
||||
|
||||
## Test 1: Testing `before_tasks` and `after_tasks`
|
||||
|
||||
1. Open a chat with an LLM (like GitHub Copilot) in this project.
|
||||
2. Ask it to generate tasks for the current directory:
|
||||
> "Please follow `/speckit.tasks` for the `./tests/hooks` directory."
|
||||
3. **Expected Behavior**:
|
||||
- Before doing any generation, the LLM should notice the `AUTOMATIC Pre-Hook` in `.specify/extensions.yml` under `before_tasks`.
|
||||
- It should state it is executing `EXECUTE_COMMAND: pre_tasks_test`.
|
||||
- It should then proceed to read the `.md` docs and produce a `tasks.md`.
|
||||
- After generation, it should output the optional `after_tasks` hook (`post_tasks_test`) block, asking if you want to run it.
|
||||
|
||||
## Test 2: Testing `before_implement` and `after_implement`
|
||||
|
||||
*(Requires `tasks.md` from Test 1 to exist)*
|
||||
|
||||
1. In the same (or new) chat, ask the LLM to implement the tasks:
|
||||
> "Please follow `/speckit.implement` for the `./tests/hooks` directory."
|
||||
2. **Expected Behavior**:
|
||||
- The LLM should first check for `before_implement` hooks.
|
||||
- It should state it is executing `EXECUTE_COMMAND: pre_implement_test` BEFORE doing any actual task execution.
|
||||
- It should evaluate the checklists and execute the code writing tasks.
|
||||
- Upon completion, it should output the optional `after_implement` hook (`post_implement_test`) block.
|
||||
|
||||
## How it works
|
||||
|
||||
The templates for these commands in `templates/commands/tasks.md` and `templates/commands/implement.md` contains strict ordered lists. The new `before_*` hooks are explicitly formulated in a **Pre-Execution Checks** section prior to the outline to ensure they're evaluated first without breaking template step numbers.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Test Setup for Hooks
|
||||
|
||||
This feature is designed to test if LLMs correctly invoke Spec Kit extensions hooks when generating tasks and implementing code.
|
||||
@@ -0,0 +1 @@
|
||||
- **User Story 1:** I want a test script that prints "Hello hooks!".
|
||||
@@ -0,0 +1 @@
|
||||
- [ ] T001 [US1] Create script that prints 'Hello hooks!' in hello.py
|
||||
@@ -0,0 +1,15 @@
|
||||
"""HTTP test helpers shared by version-related CLI tests."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def mock_urlopen_response(payload: dict) -> MagicMock:
|
||||
"""Build a urlopen context-manager mock whose read returns JSON."""
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Integration tests for the catalog stack: precedence, policy gating, search."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
|
||||
from specify_cli.bundler.services.catalog_stack import CatalogStack
|
||||
from tests.bundler_helpers import catalog_entry_dict, catalog_payload
|
||||
|
||||
|
||||
def _source(source_id, priority, policy, url="builtin://x"):
|
||||
return CatalogSource(
|
||||
id=source_id, url=url, priority=priority,
|
||||
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def _stack(sources, payloads):
|
||||
def fetcher(src):
|
||||
return payloads[src.id]
|
||||
return CatalogStack(sources, fetcher)
|
||||
|
||||
|
||||
def test_resolve_prefers_highest_precedence_source():
|
||||
sources = [
|
||||
_source("low", 2, "install-allowed"),
|
||||
_source("high", 1, "discovery-only"),
|
||||
]
|
||||
payloads = {
|
||||
"high": catalog_payload({"b": catalog_entry_dict("b", version="9.0.0")}),
|
||||
"low": catalog_payload({"b": catalog_entry_dict("b", version="1.0.0")}),
|
||||
}
|
||||
resolved = _stack(sources, payloads).resolve("b")
|
||||
assert resolved.source.id == "high"
|
||||
assert resolved.entry.version == "9.0.0"
|
||||
assert resolved.install_allowed is False
|
||||
|
||||
|
||||
def test_resolve_unknown_bundle_errors():
|
||||
stack = _stack(
|
||||
[_source("only", 1, "install-allowed")],
|
||||
{"only": catalog_payload({})},
|
||||
)
|
||||
with pytest.raises(BundlerError, match="not found"):
|
||||
stack.resolve("missing")
|
||||
|
||||
|
||||
def test_search_dedupes_by_precedence_and_filters():
|
||||
sources = [_source("a", 1, "install-allowed"), _source("b", 2, "install-allowed")]
|
||||
payloads = {
|
||||
"a": catalog_payload({
|
||||
"alpha": catalog_entry_dict("alpha", role="developer"),
|
||||
}),
|
||||
"b": catalog_payload({
|
||||
"alpha": catalog_entry_dict("alpha", version="0.0.1"),
|
||||
"beta": catalog_entry_dict("beta", role="qa"),
|
||||
}),
|
||||
}
|
||||
stack = _stack(sources, payloads)
|
||||
|
||||
all_results = stack.search()
|
||||
ids = [r.entry.id for r in all_results]
|
||||
assert ids == ["alpha", "beta"]
|
||||
# alpha resolved from the higher-precedence source 'a'.
|
||||
alpha = next(r for r in all_results if r.entry.id == "alpha")
|
||||
assert alpha.source.id == "a"
|
||||
|
||||
qa_only = stack.search("qa")
|
||||
assert [r.entry.id for r in qa_only] == ["beta"]
|
||||
|
||||
|
||||
def test_search_does_not_surface_a_shadowed_lower_precedence_entry():
|
||||
"""Search must resolve each id at its highest-precedence source, then
|
||||
filter — never fall through to a shadowed lower-precedence entry the query
|
||||
happens to match.
|
||||
|
||||
If the query matched only the lower-precedence copy of an id, search used
|
||||
to return that copy, even though `resolve()`/install always use the
|
||||
higher-precedence one. That advertised a bundle (name/version/source) the
|
||||
user could never actually get.
|
||||
"""
|
||||
sources = [_source("high", 1, "install-allowed"), _source("low", 2, "install-allowed")]
|
||||
payloads = {
|
||||
# Highest-precedence entry for 'shared' does NOT match "widget".
|
||||
"high": catalog_payload({
|
||||
"shared": catalog_entry_dict(
|
||||
"shared", name="Alpha Tool", role="developer",
|
||||
description="nothing relevant", version="2.0.0",
|
||||
),
|
||||
}),
|
||||
# Lower-precedence entry for the same id DOES match "widget".
|
||||
"low": catalog_payload({
|
||||
"shared": catalog_entry_dict(
|
||||
"shared", name="Searchable Widget", version="1.0.0",
|
||||
),
|
||||
}),
|
||||
}
|
||||
stack = _stack(sources, payloads)
|
||||
|
||||
# resolve() uses the high-precedence entry.
|
||||
assert stack.resolve("shared").source.id == "high"
|
||||
|
||||
# A query that only the shadowed low-precedence entry matches returns
|
||||
# nothing — search agrees with resolve().
|
||||
assert stack.search("widget") == []
|
||||
|
||||
# And a query the high-precedence entry matches returns it (from 'high').
|
||||
alpha = stack.search("alpha tool")
|
||||
assert [r.entry.id for r in alpha] == ["shared"]
|
||||
assert alpha[0].source.id == "high"
|
||||
|
||||
|
||||
def test_unreachable_source_raises_named_error():
|
||||
def fetcher(src):
|
||||
raise RuntimeError("boom")
|
||||
stack = CatalogStack([_source("bad", 1, "install-allowed")], fetcher)
|
||||
with pytest.raises(BundlerError, match="bad"):
|
||||
stack.resolve("anything")
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Install-time initialization and integration precedence (T049, T050).
|
||||
|
||||
``specify bundle install`` into an uninitialized directory must scaffold a Spec
|
||||
Kit project first (FR-012), choosing the integration by precedence (FR-013):
|
||||
explicit ``--integration`` override → bundle-declared integration → default.
|
||||
The end-to-end test runs fully offline against bundled assets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.commands.bundle import _resolve_init_integration
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _manifest(**overrides):
|
||||
data = valid_manifest_dict(**overrides)
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_precedence_override_wins():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
assert _resolve_init_integration("gemini", manifest) == "gemini"
|
||||
|
||||
|
||||
def test_precedence_bundle_declared_when_no_override():
|
||||
manifest = _manifest(integration={"id": "claude"})
|
||||
assert _resolve_init_integration(None, manifest) == "claude"
|
||||
|
||||
|
||||
def test_precedence_default_when_unspecified():
|
||||
manifest = _manifest()
|
||||
assert _resolve_init_integration(None, manifest) == "copilot"
|
||||
assert _resolve_init_integration(None, None) == "copilot"
|
||||
|
||||
|
||||
def _build_mini(tmp_path: Path) -> Path:
|
||||
bundle = tmp_path / "mini"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "mini",
|
||||
"name": "Mini",
|
||||
"version": "1.0.0",
|
||||
"role": "developer",
|
||||
"description": "minimal",
|
||||
"author": "tests",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {"extensions": [{"id": "agent-context", "version": "1.0.0"}]},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle / "README.md").write_text("# Mini\n", encoding="utf-8")
|
||||
return build_bundle(bundle).artifact_path
|
||||
|
||||
|
||||
def test_install_initializes_uninitialized_project(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
artifact = _build_mini(tmp_path)
|
||||
|
||||
previous = Path.cwd()
|
||||
os.chdir(project)
|
||||
try:
|
||||
result = runner.invoke(
|
||||
app, ["bundle", "install", str(artifact), "--offline"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
|
||||
assert (project / ".specify").is_dir()
|
||||
marker = project / ".specify" / "integration.json"
|
||||
assert marker.exists()
|
||||
data = json.loads(marker.read_text(encoding="utf-8"))
|
||||
assert "copilot" in json.dumps(data)
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Integration tests for the install → record → remove lifecycle (offline, fake installer).
|
||||
|
||||
Uses :class:`FakeInstaller` so no network or real primitive machinery is touched
|
||||
(Constitution Principle II network-mocking, Principle IV offline-first).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.manifest import BundleManifest
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
from specify_cli.bundler.services.installer import install_bundle, remove_bundle
|
||||
from specify_cli.bundler.services.resolver import resolve_install_plan
|
||||
from tests.bundler_helpers import FakeInstaller, make_project, valid_manifest_dict
|
||||
|
||||
|
||||
def _plan(manifest):
|
||||
return resolve_install_plan(
|
||||
manifest, speckit_version="0.11.2", active_integration="copilot"
|
||||
)
|
||||
|
||||
|
||||
def test_install_records_and_invokes_primitives(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
result = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
assert len(result.installed) == 4
|
||||
assert len(installer.install_calls) == 4
|
||||
records = load_records(tmp_path)
|
||||
assert len(records) == 1
|
||||
assert records[0].bundle_id == "demo-bundle"
|
||||
|
||||
|
||||
def test_install_is_idempotent(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
second = install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# Second install adds nothing and does not duplicate the record.
|
||||
assert second.installed == []
|
||||
assert len(second.skipped) == 4
|
||||
assert len(load_records(tmp_path)) == 1
|
||||
|
||||
|
||||
def test_partial_failure_rolls_back_and_records_nothing(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller(fail_on="preset-a")
|
||||
|
||||
with pytest.raises(BundlerError):
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# ext-a was installed first, then rolled back; no record persisted.
|
||||
assert installer.installed == set()
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
def test_remove_is_non_collateral(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
# Bundle A provides a shared preset; Bundle B also provides it.
|
||||
data_a = valid_manifest_dict()
|
||||
data_a["bundle"]["id"] = "a"
|
||||
data_b = valid_manifest_dict()
|
||||
data_b["bundle"]["id"] = "b"
|
||||
data_b["provides"] = {"presets": [
|
||||
{"id": "preset-a", "version": "2.0.0", "priority": 10, "strategy": "append"}
|
||||
]}
|
||||
|
||||
man_a = BundleManifest.from_dict(data_a)
|
||||
man_b = BundleManifest.from_dict(data_b)
|
||||
install_bundle(tmp_path, _plan(man_a), installer, manifest=man_a)
|
||||
install_bundle(tmp_path, _plan(man_b), installer, manifest=man_b)
|
||||
|
||||
# Removing B must NOT uninstall preset-a (still needed by A).
|
||||
result = remove_bundle(tmp_path, "b", installer)
|
||||
assert ("presets", "preset-a") in {(c.kind, c.id) for c in result.skipped}
|
||||
assert installer.is_installed(tmp_path, man_a.presets[0]) is True
|
||||
|
||||
remaining = {r.bundle_id for r in load_records(tmp_path)}
|
||||
assert remaining == {"a"}
|
||||
|
||||
|
||||
def test_remove_unknown_bundle_errors(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
with pytest.raises(BundlerError, match="not installed"):
|
||||
remove_bundle(tmp_path, "ghost", FakeInstaller())
|
||||
|
||||
|
||||
def test_remove_reports_uninstalled_not_installed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
result = remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
# Removal flows populate the dedicated ``uninstalled`` list; ``installed``
|
||||
# stays empty so the result type is never ambiguous for callers.
|
||||
assert result.installed == []
|
||||
assert len(result.uninstalled) == 4
|
||||
assert installer.installed == set()
|
||||
|
||||
|
||||
def test_remove_counts_only_components_actually_removed(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
# Simulate one contributed component already gone from disk (e.g. removed
|
||||
# out of band). It must not be reported as uninstalled and remove() must
|
||||
# not be called for it.
|
||||
gone = manifest.components[0]
|
||||
installer.installed.discard((gone.kind, gone.id))
|
||||
|
||||
result = remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
|
||||
assert len(result.uninstalled) == 3
|
||||
assert (gone.kind, gone.id) not in installer.remove_calls
|
||||
assert gone in result.skipped
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# With refresh, already-installed components are re-applied, not skipped.
|
||||
assert result.skipped == []
|
||||
assert len(result.refreshed) == 4
|
||||
assert len(installer.refresh_calls) == 4
|
||||
assert result.changed is True
|
||||
|
||||
|
||||
def test_refresh_falls_back_to_install_without_hook(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
|
||||
class NoRefreshInstaller(FakeInstaller):
|
||||
refresh = None # type: ignore[assignment]
|
||||
|
||||
installer = NoRefreshInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
before = len(installer.install_calls)
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# No refresh hook → re-install path keeps components current.
|
||||
assert len(result.refreshed) == 4
|
||||
assert len(installer.install_calls) == before + 4
|
||||
|
||||
|
||||
def test_update_preserves_original_installed_at(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
original = load_records(tmp_path)[0].installed_at
|
||||
|
||||
# A refresh (bundle update) must not rewrite the original install timestamp.
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True)
|
||||
|
||||
assert load_records(tmp_path)[0].installed_at == original
|
||||
|
||||
|
||||
def test_refresh_does_not_touch_independently_installed_component(tmp_path: Path):
|
||||
# bundle update (refresh) must not re-apply a component installed
|
||||
# independently and tracked by no bundle — refreshing it would be a
|
||||
# collateral change to something the bundle does not own (FR-022).
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
installer.installed.add(("extensions", "ext-a"))
|
||||
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True
|
||||
)
|
||||
|
||||
# ext-a is skipped (not refreshed) and never attributed to the bundle.
|
||||
assert ("extensions", "ext-a") not in installer.refresh_calls
|
||||
assert ("extensions", "ext-a") in {(c.kind, c.id) for c in result.skipped}
|
||||
assert ("extensions", "ext-a") not in {(c.kind, c.id) for c in result.refreshed}
|
||||
contributed = {
|
||||
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
|
||||
}
|
||||
assert ("extensions", "ext-a") not in contributed
|
||||
|
||||
|
||||
def test_pre_existing_component_is_not_attributed_or_removed(tmp_path: Path):
|
||||
# A component installed independently (before any bundle) must not be
|
||||
# attributed to the bundle, so removing the bundle never uninstalls it
|
||||
# (FR-022, no collateral removal).
|
||||
make_project(tmp_path)
|
||||
manifest = BundleManifest.from_dict(valid_manifest_dict())
|
||||
installer = FakeInstaller()
|
||||
# Pre-install ext-a independently — no bundle record references it yet.
|
||||
installer.installed.add(("extensions", "ext-a"))
|
||||
|
||||
install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest)
|
||||
|
||||
contributed = {
|
||||
(c.kind, c.id) for c in load_records(tmp_path)[0].contributed_components
|
||||
}
|
||||
assert ("extensions", "ext-a") not in contributed
|
||||
|
||||
remove_bundle(tmp_path, "demo-bundle", installer)
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
|
||||
|
||||
def _bundle(manifest_id, ext_ids, *, version="1.0.0"):
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["id"] = manifest_id
|
||||
data["bundle"]["version"] = version
|
||||
data["provides"] = {
|
||||
"extensions": [{"id": e, "version": version} for e in ext_ids]
|
||||
}
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path):
|
||||
"""`bundle update` must uninstall components the new version no longer
|
||||
ships, instead of orphaning them (installed on disk, tracked by nothing)."""
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||
assert ("extensions", "ext-b") in installer.installed
|
||||
|
||||
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||
result = install_bundle(
|
||||
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||
)
|
||||
|
||||
# ext-b was dropped by v2 -> uninstalled and reported.
|
||||
assert ("extensions", "ext-b") in installer.remove_calls
|
||||
assert ("extensions", "ext-b") in {(c.kind, c.id) for c in result.uninstalled}
|
||||
assert ("extensions", "ext-b") not in installer.installed
|
||||
assert ("extensions", "ext-a") in installer.installed
|
||||
|
||||
# The saved record lists only ext-a.
|
||||
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||
keys = {(c.kind, c.id) for c in rec.contributed_components}
|
||||
assert ("extensions", "ext-a") in keys
|
||||
assert ("extensions", "ext-b") not in keys
|
||||
|
||||
|
||||
def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
|
||||
"""A dropped component still owned by another bundle stays installed."""
|
||||
make_project(tmp_path)
|
||||
installer = FakeInstaller()
|
||||
|
||||
man_sib = _bundle("sibling", ["ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_sib), installer, manifest=man_sib)
|
||||
|
||||
man_v1 = _bundle("demo", ["ext-a", "ext-b"])
|
||||
install_bundle(tmp_path, _plan(man_v1), installer, manifest=man_v1)
|
||||
|
||||
man_v2 = _bundle("demo", ["ext-a"], version="2.0.0")
|
||||
install_bundle(
|
||||
tmp_path, _plan(man_v2), installer, manifest=man_v2, refresh=True
|
||||
)
|
||||
|
||||
# ext-b is still needed by 'sibling' -> not removed, stays installed.
|
||||
assert ("extensions", "ext-b") not in installer.remove_calls
|
||||
assert ("extensions", "ext-b") in installer.installed
|
||||
|
||||
# But demo's record no longer attributes it.
|
||||
rec = next(r for r in load_records(tmp_path) if r.bundle_id == "demo")
|
||||
assert ("extensions", "ext-b") not in {
|
||||
(c.kind, c.id) for c in rec.contributed_components
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for installing a bundle from a local artifact/path (T045).
|
||||
|
||||
The resolution-level tests are pure; the end-to-end test installs the bundled
|
||||
``agent-context`` extension fully offline from a built ``.zip`` artifact,
|
||||
proving the real in-process primitive dispatch (T044) works without a network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.commands.bundle import _local_manifest_source
|
||||
from tests.bundler_helpers import make_project, valid_manifest_dict, write_manifest
|
||||
|
||||
|
||||
def test_local_source_none_for_non_path():
|
||||
assert _local_manifest_source("some-catalog-bundle-id") is None
|
||||
|
||||
|
||||
def test_local_source_from_directory(tmp_path: Path):
|
||||
write_manifest(tmp_path, valid_manifest_dict())
|
||||
manifest = _local_manifest_source(str(tmp_path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_from_bundle_yml(tmp_path: Path):
|
||||
path = write_manifest(tmp_path, valid_manifest_dict())
|
||||
manifest = _local_manifest_source(str(path))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_from_zip_artifact(tmp_path: Path):
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
bundle_dir.mkdir()
|
||||
write_manifest(bundle_dir, valid_manifest_dict())
|
||||
(bundle_dir / "README.md").write_text("# demo\n", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
|
||||
assert result.exit_code == 0, result.output
|
||||
artifact = next(bundle_dir.glob("*.zip"))
|
||||
|
||||
manifest = _local_manifest_source(str(artifact))
|
||||
assert manifest is not None
|
||||
assert manifest.bundle.id == "demo-bundle"
|
||||
|
||||
|
||||
def test_local_source_rejects_unknown_file(tmp_path: Path):
|
||||
weird = tmp_path / "thing.txt"
|
||||
weird.write_text("nope", encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="not a recognised bundle source"):
|
||||
_local_manifest_source(str(weird))
|
||||
|
||||
|
||||
def test_install_bundled_extension_from_zip_offline(tmp_path: Path):
|
||||
"""End-to-end: build → install (offline, local .zip) → list → remove."""
|
||||
project = make_project(tmp_path / "proj")
|
||||
|
||||
bundle_dir = tmp_path / "mini"
|
||||
bundle_dir.mkdir()
|
||||
(bundle_dir / "bundle.yml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"bundle": {
|
||||
"id": "mini",
|
||||
"name": "Mini",
|
||||
"version": "1.0.0",
|
||||
"role": "developer",
|
||||
"description": "minimal",
|
||||
"author": "tests",
|
||||
"license": "MIT",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"extensions": [{"id": "agent-context", "version": "1.0.0"}]
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(bundle_dir / "README.md").write_text("# Mini\n", encoding="utf-8")
|
||||
|
||||
runner = CliRunner()
|
||||
previous = Path.cwd()
|
||||
os.chdir(project)
|
||||
try:
|
||||
build = runner.invoke(app, ["bundle", "build", "--path", str(bundle_dir)])
|
||||
assert build.exit_code == 0, build.output
|
||||
artifact = next(bundle_dir.glob("*.zip"))
|
||||
|
||||
install = runner.invoke(app, ["bundle", "install", str(artifact), "--offline"])
|
||||
assert install.exit_code == 0, install.output
|
||||
|
||||
from specify_cli.extensions import ExtensionManager
|
||||
|
||||
assert ExtensionManager(project).registry.is_installed("agent-context")
|
||||
|
||||
listing = runner.invoke(app, ["bundle", "list"])
|
||||
assert "mini" in listing.output
|
||||
|
||||
remove = runner.invoke(app, ["bundle", "remove", "mini"])
|
||||
assert remove.exit_code == 0, remove.output
|
||||
assert not ExtensionManager(project).registry.is_installed("agent-context")
|
||||
finally:
|
||||
os.chdir(previous)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Offline-first tests (Constitution Principle IV).
|
||||
|
||||
Assert that consume/author flows work with no network access: built-in catalogs
|
||||
resolve offline, file:// catalogs resolve offline, and http(s) sources are
|
||||
refused (never silently attempted) when network is disabled.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.models.catalog import CatalogSource, InstallPolicy, Scope
|
||||
from specify_cli.bundler.services.adapters import make_catalog_fetcher
|
||||
from specify_cli.bundler.services.catalog_stack import CatalogStack
|
||||
from tests.bundler_helpers import catalog_entry_dict, write_catalog_file
|
||||
|
||||
|
||||
def _src(source_id, url, priority=1, policy="install-allowed"):
|
||||
return CatalogSource(
|
||||
id=source_id, url=url, priority=priority,
|
||||
install_policy=InstallPolicy(policy), scope=Scope.PROJECT,
|
||||
)
|
||||
|
||||
|
||||
def test_builtin_catalog_resolves_offline():
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("default", "builtin://default")], fetcher)
|
||||
# Built-in default ships empty; search works without network and returns [].
|
||||
assert stack.search() == []
|
||||
|
||||
|
||||
def test_file_catalog_resolves_offline(tmp_path: Path):
|
||||
catalog_path = tmp_path / "catalog.json"
|
||||
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", str(catalog_path))], fetcher)
|
||||
resolved = stack.resolve("demo")
|
||||
assert resolved.entry.id == "demo"
|
||||
|
||||
|
||||
def test_http_source_refused_when_offline():
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("remote", "https://example.com/catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="Network access disabled"):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_missing_file_catalog_errors_offline(tmp_path: Path):
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", str(tmp_path / "nope.json"))], fetcher)
|
||||
with pytest.raises(BundlerError):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_file_url_catalog_resolves_offline(tmp_path: Path):
|
||||
catalog_path = tmp_path / "catalog.json"
|
||||
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
|
||||
fetcher = make_catalog_fetcher(allow_network=False)
|
||||
stack = CatalogStack([_src("local", catalog_path.as_uri())], fetcher)
|
||||
resolved = stack.resolve("demo")
|
||||
assert resolved.entry.id == "demo"
|
||||
|
||||
|
||||
def test_plain_http_remote_rejected_before_network():
|
||||
# HTTPS is required for non-localhost catalogs; reject http:// up front.
|
||||
fetcher = make_catalog_fetcher(allow_network=True)
|
||||
stack = CatalogStack([_src("remote", "http://example.com/catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="must use HTTPS"):
|
||||
stack.resolve("anything")
|
||||
|
||||
|
||||
def test_remote_url_without_host_rejected():
|
||||
fetcher = make_catalog_fetcher(allow_network=True)
|
||||
stack = CatalogStack([_src("remote", "https:///catalog.json")], fetcher)
|
||||
with pytest.raises(BundlerError, match="valid URL with a host"):
|
||||
stack.resolve("anything")
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Security tests: path-traversal / symlink confinement (Constitution Principle V).
|
||||
|
||||
These assert the bundler refuses to read or write outside an allowed root, so a
|
||||
malicious manifest or artifact path cannot escape the project/bundle directory.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.lib.yamlio import ensure_within, is_safe_relpath
|
||||
|
||||
|
||||
def test_ensure_within_allows_child(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
child = root / "sub" / "file.txt"
|
||||
assert ensure_within(root, child) == child.resolve()
|
||||
|
||||
|
||||
def test_ensure_within_rejects_parent_traversal(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
escape = root / ".." / "secret.txt"
|
||||
with pytest.raises(BundlerError, match="escapes"):
|
||||
ensure_within(root, escape)
|
||||
|
||||
|
||||
def test_ensure_within_rejects_absolute_outside(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
with pytest.raises(BundlerError):
|
||||
ensure_within(root, Path("/etc/passwd"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="symlink semantics differ on Windows")
|
||||
def test_ensure_within_rejects_symlink_escape(tmp_path: Path):
|
||||
root = tmp_path / "bundle"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
link = root / "link.txt"
|
||||
link.symlink_to(outside)
|
||||
with pytest.raises(BundlerError, match="escapes"):
|
||||
ensure_within(root, link)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rel,safe", [
|
||||
("a/b.txt", True),
|
||||
("./a.txt", True),
|
||||
("../escape", False),
|
||||
("a/../../escape", False),
|
||||
("/abs", False),
|
||||
("C:/abs", False),
|
||||
("C:\\abs", False),
|
||||
("\\\\server\\share", False),
|
||||
("", False),
|
||||
])
|
||||
def test_is_safe_relpath(rel, safe):
|
||||
assert is_safe_relpath(rel) is safe
|
||||
|
||||
|
||||
def test_build_skips_symlinks(tmp_path: Path):
|
||||
"""Packager must not follow symlinks out of the bundle dir."""
|
||||
import yaml
|
||||
|
||||
from specify_cli.bundler.services.packager import build_bundle
|
||||
from tests.bundler_helpers import valid_manifest_dict
|
||||
|
||||
bundle = tmp_path / "bundle"
|
||||
bundle.mkdir()
|
||||
(bundle / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(bundle / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
|
||||
if os.name != "nt":
|
||||
secret = tmp_path / "secret.txt"
|
||||
secret.write_text("top secret", encoding="utf-8")
|
||||
(bundle / "leak.txt").symlink_to(secret)
|
||||
|
||||
result = build_bundle(bundle, output_dir=tmp_path / "out")
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(result.artifact_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert "leak.txt" not in names
|
||||
assert "bundle.yml" in names
|
||||
|
||||
|
||||
def test_load_records_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Reading bundle-records.json must honour the same confinement as writes:
|
||||
# a symlinked .specify pointing outside project_root is refused.
|
||||
from specify_cli.bundler.models.records import load_records
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-records.json").write_text(
|
||||
'{"schema_version": "1.0", "bundles": []}', encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
load_records(project)
|
||||
|
||||
|
||||
def test_active_integration_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
# Reading the integration marker must not follow a .specify symlink that
|
||||
# resolves outside project_root; an escape is treated as "not determinable".
|
||||
from specify_cli.bundler.lib.project import active_integration
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "integration.json").write_text(
|
||||
'{"integration": "leaked"}', encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
assert active_integration(project) is None
|
||||
|
||||
|
||||
def test_read_catalog_config_refuses_symlinked_specify_escape(tmp_path: Path):
|
||||
from specify_cli.bundler.commands_impl import catalog_config as cc
|
||||
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-catalogs.yml").write_text(
|
||||
"schema_version: '1.0'\ncatalogs: []\n", encoding="utf-8"
|
||||
)
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_load_source_stack_refuses_symlinked_specify_dir(tmp_path: Path):
|
||||
from specify_cli.bundler.models.catalog import load_source_stack
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "bundle-catalogs.yml").write_text("catalogs: []\n", encoding="utf-8")
|
||||
try:
|
||||
(project / ".specify").symlink_to(outside, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
with pytest.raises(BundlerError, match="escapes the allowed root"):
|
||||
load_source_stack(project)
|
||||
|
||||
|
||||
def test_find_project_root_ignores_symlinked_specify(tmp_path: Path):
|
||||
from specify_cli.bundler.lib.project import find_project_root
|
||||
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
try:
|
||||
(project / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
# A symlinked .specify must not be accepted as a project root.
|
||||
assert find_project_root(project) is None
|
||||
|
||||
|
||||
def test_find_project_root_override_errors_on_symlinked_specify(tmp_path: Path, monkeypatch):
|
||||
"""The SPECIFY_INIT_DIR override path refuses a symlinked .specify too,
|
||||
matching the cwd loop path (regression: the override returned early and
|
||||
skipped the symlink guard)."""
|
||||
from specify_cli.bundler.lib.project import find_project_root
|
||||
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
try:
|
||||
(project / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("symlinks not supported on this platform")
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(project))
|
||||
with pytest.raises(BundlerError, match="symlinked \\.specify"):
|
||||
find_project_root(None)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Shared test helpers for integration tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
|
||||
|
||||
def _redirect_home(monkeypatch: pytest.MonkeyPatch, home) -> None:
|
||||
"""Point HOME/USERPROFILE/XDG env vars at an isolated *home* directory."""
|
||||
for path in (home, home / ".cache", home / ".config", home / ".local" / "share"):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("USERPROFILE", str(home))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(home / ".cache"))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _isolate_integration_home_session(tmp_path_factory):
|
||||
"""Isolate the user home for setup that runs outside a test function.
|
||||
|
||||
The per-test fixture below re-points HOME for each test, but function-scoped
|
||||
fixtures do not apply to module-/session-scoped fixtures. Some of those (e.g.
|
||||
the ``status_*_template`` fixtures in ``test_integration_subcommand.py``) run
|
||||
``specify init`` during setup, before any per-test isolation takes effect.
|
||||
A standalone ``MonkeyPatch`` gives them an isolated home too.
|
||||
"""
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
_redirect_home(monkeypatch, tmp_path_factory.mktemp("session-home"))
|
||||
yield
|
||||
monkeypatch.undo()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_integration_home(monkeypatch: pytest.MonkeyPatch, tmp_path):
|
||||
"""Keep integration tests from reading or writing the real user home."""
|
||||
_redirect_home(monkeypatch, tmp_path / "home")
|
||||
|
||||
|
||||
class StubIntegration(MarkdownIntegration):
|
||||
"""Minimal concrete integration for testing."""
|
||||
|
||||
key = "stub"
|
||||
config = {
|
||||
"name": "Stub Agent",
|
||||
"folder": ".stub/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".stub/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import (
|
||||
IntegrationBase,
|
||||
IntegrationOption,
|
||||
MarkdownIntegration,
|
||||
SkillsIntegration,
|
||||
)
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
from .conftest import StubIntegration
|
||||
|
||||
|
||||
class TestIntegrationOption:
|
||||
def test_defaults(self):
|
||||
opt = IntegrationOption(name="--flag")
|
||||
assert opt.name == "--flag"
|
||||
assert opt.is_flag is False
|
||||
assert opt.required is False
|
||||
assert opt.default is None
|
||||
assert opt.help == ""
|
||||
|
||||
def test_flag_option(self):
|
||||
opt = IntegrationOption(name="--skills", is_flag=True, default=True, help="Enable skills")
|
||||
assert opt.is_flag is True
|
||||
assert opt.default is True
|
||||
assert opt.help == "Enable skills"
|
||||
|
||||
def test_required_option(self):
|
||||
opt = IntegrationOption(name="--commands-dir", required=True, help="Dir path")
|
||||
assert opt.required is True
|
||||
|
||||
def test_frozen(self):
|
||||
opt = IntegrationOption(name="--x")
|
||||
with pytest.raises(AttributeError):
|
||||
opt.name = "--y" # type: ignore[misc]
|
||||
|
||||
|
||||
class TestIntegrationBase:
|
||||
def test_key_and_config(self):
|
||||
i = StubIntegration()
|
||||
assert i.key == "stub"
|
||||
assert i.config["name"] == "Stub Agent"
|
||||
assert i.registrar_config["format"] == "markdown"
|
||||
|
||||
def test_options_default_empty(self):
|
||||
assert StubIntegration.options() == []
|
||||
|
||||
def test_shared_commands_dir(self):
|
||||
i = StubIntegration()
|
||||
cmd_dir = i.shared_commands_dir()
|
||||
assert cmd_dir is not None
|
||||
assert cmd_dir.is_dir()
|
||||
|
||||
def test_setup_uses_shared_templates(self, tmp_path):
|
||||
i = StubIntegration()
|
||||
manifest = IntegrationManifest("stub", tmp_path)
|
||||
created = i.setup(tmp_path, manifest)
|
||||
assert len(created) > 0
|
||||
for f in created:
|
||||
assert f.parent == tmp_path / ".stub" / "commands"
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
def test_setup_copies_templates(self, tmp_path, monkeypatch):
|
||||
tpl = tmp_path / "_templates"
|
||||
tpl.mkdir()
|
||||
(tpl / "plan.md").write_text("plan content", encoding="utf-8")
|
||||
(tpl / "specify.md").write_text("spec content", encoding="utf-8")
|
||||
|
||||
i = StubIntegration()
|
||||
monkeypatch.setattr(type(i), "list_command_templates", lambda self: sorted(tpl.glob("*.md")))
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
created = i.setup(project, IntegrationManifest("stub", project))
|
||||
assert len(created) == 2
|
||||
assert (project / ".stub" / "commands" / "speckit.plan.md").exists()
|
||||
assert (project / ".stub" / "commands" / "speckit.specify.md").exists()
|
||||
|
||||
def test_install_delegates_to_setup(self, tmp_path):
|
||||
i = StubIntegration()
|
||||
manifest = IntegrationManifest("stub", tmp_path)
|
||||
result = i.install(tmp_path, manifest)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_uninstall_delegates_to_teardown(self, tmp_path):
|
||||
i = StubIntegration()
|
||||
manifest = IntegrationManifest("stub", tmp_path)
|
||||
removed, skipped = i.uninstall(tmp_path, manifest)
|
||||
assert removed == []
|
||||
assert skipped == []
|
||||
|
||||
|
||||
class TestMarkdownIntegration:
|
||||
def test_is_subclass_of_base(self):
|
||||
assert issubclass(MarkdownIntegration, IntegrationBase)
|
||||
|
||||
def test_stub_is_markdown(self):
|
||||
assert isinstance(StubIntegration(), MarkdownIntegration)
|
||||
|
||||
|
||||
class TestBasePrimitives:
|
||||
def test_shared_commands_dir_returns_path(self):
|
||||
i = StubIntegration()
|
||||
cmd_dir = i.shared_commands_dir()
|
||||
assert cmd_dir is not None
|
||||
assert cmd_dir.is_dir()
|
||||
|
||||
def test_shared_templates_dir_returns_path(self):
|
||||
i = StubIntegration()
|
||||
tpl_dir = i.shared_templates_dir()
|
||||
assert tpl_dir is not None
|
||||
assert tpl_dir.is_dir()
|
||||
|
||||
def test_list_command_templates_returns_md_files(self):
|
||||
i = StubIntegration()
|
||||
templates = i.list_command_templates()
|
||||
assert len(templates) > 0
|
||||
assert all(t.suffix == ".md" for t in templates)
|
||||
|
||||
def test_list_command_templates_keeps_checklist_after_plan(self):
|
||||
i = StubIntegration()
|
||||
stems = [template.stem for template in i.list_command_templates()]
|
||||
assert stems.index("plan") < stems.index("checklist")
|
||||
|
||||
def test_command_filename_default(self):
|
||||
i = StubIntegration()
|
||||
assert i.command_filename("plan") == "speckit.plan.md"
|
||||
|
||||
def test_commands_dest(self, tmp_path):
|
||||
i = StubIntegration()
|
||||
dest = i.commands_dest(tmp_path)
|
||||
assert dest == tmp_path / ".stub" / "commands"
|
||||
|
||||
def test_commands_dest_no_config_raises(self, tmp_path):
|
||||
class NoConfig(MarkdownIntegration):
|
||||
key = "noconfig"
|
||||
with pytest.raises(ValueError, match="config is not set"):
|
||||
NoConfig().commands_dest(tmp_path)
|
||||
|
||||
def test_copy_command_to_directory(self, tmp_path):
|
||||
src = tmp_path / "source.md"
|
||||
src.write_text("content", encoding="utf-8")
|
||||
dest_dir = tmp_path / "output"
|
||||
result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md")
|
||||
assert result == dest_dir / "speckit.plan.md"
|
||||
assert result.read_text(encoding="utf-8") == "content"
|
||||
|
||||
def test_record_file_in_manifest(self, tmp_path):
|
||||
f = tmp_path / "f.txt"
|
||||
f.write_text("hello", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
IntegrationBase.record_file_in_manifest(f, tmp_path, m)
|
||||
assert "f.txt" in m.files
|
||||
|
||||
def test_write_file_and_record(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
dest = tmp_path / "sub" / "f.txt"
|
||||
result = IntegrationBase.write_file_and_record("content", dest, tmp_path, m)
|
||||
assert result == dest
|
||||
assert dest.read_text(encoding="utf-8") == "content"
|
||||
assert "sub/f.txt" in m.files
|
||||
|
||||
def test_setup_copies_shared_templates(self, tmp_path):
|
||||
i = StubIntegration()
|
||||
m = IntegrationManifest("stub", tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
for f in created:
|
||||
assert f.parent.name == "commands"
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
|
||||
class TestBuildCommandInvocation:
|
||||
"""Tests for build_command_invocation across integration types."""
|
||||
|
||||
def test_base_core_command_dotted(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("speckit.plan") == "/speckit.plan"
|
||||
|
||||
def test_base_core_command_bare(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("plan") == "/speckit.plan"
|
||||
|
||||
def test_base_core_command_with_args(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("plan", "my feature") == "/speckit.plan my feature"
|
||||
|
||||
def test_base_extension_command(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("speckit.git.commit") == "/speckit.git.commit"
|
||||
|
||||
def test_base_extension_command_bare(self):
|
||||
i = StubIntegration()
|
||||
assert i.build_command_invocation("git.commit") == "/speckit.git.commit"
|
||||
|
||||
def test_skills_core_command(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("codex")
|
||||
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
|
||||
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||
|
||||
def test_skills_extension_command(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("codex")
|
||||
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||
assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
|
||||
|
||||
def test_skills_extension_command_with_args(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("codex")
|
||||
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
|
||||
|
||||
|
||||
class TestResolveCommandRefs:
|
||||
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
|
||||
|
||||
def test_dot_separator_core_command(self):
|
||||
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "Run `/speckit.plan` to plan."
|
||||
|
||||
def test_hyphen_separator_core_command(self):
|
||||
text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
|
||||
result = IntegrationBase.resolve_command_refs(text, "-")
|
||||
assert result == "Run `/speckit-plan` to plan."
|
||||
|
||||
def test_multiple_placeholders(self):
|
||||
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "/speckit.specify then /speckit.plan then /speckit.tasks"
|
||||
|
||||
def test_extension_command_dot(self):
|
||||
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "Run /speckit.git.commit to commit."
|
||||
|
||||
def test_extension_command_hyphen(self):
|
||||
text = "Run __SPECKIT_COMMAND_GIT_COMMIT__ to commit."
|
||||
result = IntegrationBase.resolve_command_refs(text, "-")
|
||||
assert result == "Run /speckit-git-commit to commit."
|
||||
|
||||
def test_no_placeholders_unchanged(self):
|
||||
text = "No placeholders here."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_default_separator_is_dot(self):
|
||||
text = "__SPECKIT_COMMAND_PLAN__"
|
||||
assert IntegrationBase.resolve_command_refs(text) == "/speckit.plan"
|
||||
|
||||
def test_invoke_separator_class_attribute(self):
|
||||
assert IntegrationBase.invoke_separator == "."
|
||||
assert SkillsIntegration.invoke_separator == "-"
|
||||
|
||||
def test_effective_invoke_separator_default(self):
|
||||
"""Base classes return invoke_separator regardless of parsed_options."""
|
||||
from .conftest import StubIntegration
|
||||
stub = StubIntegration()
|
||||
assert stub.effective_invoke_separator() == "."
|
||||
assert stub.effective_invoke_separator({"skills": True}) == "."
|
||||
|
||||
def test_process_template_resolves_placeholders(self):
|
||||
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
|
||||
result = IntegrationBase.process_template(
|
||||
content, "test-agent", "sh", invoke_separator="."
|
||||
)
|
||||
assert "/speckit.plan" in result
|
||||
assert "__SPECKIT_COMMAND_" not in result
|
||||
|
||||
def test_process_template_skills_separator(self):
|
||||
content = "---\ndescription: test\n---\nRun __SPECKIT_COMMAND_PLAN__ now."
|
||||
result = IntegrationBase.process_template(
|
||||
content, "test-agent", "sh", invoke_separator="-"
|
||||
)
|
||||
assert "/speckit-plan" in result
|
||||
assert "__SPECKIT_COMMAND_" not in result
|
||||
|
||||
def test_unclosed_placeholder_unchanged(self):
|
||||
text = "Run __SPECKIT_COMMAND_PLAN to plan."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_empty_name_not_matched(self):
|
||||
text = "Run __SPECKIT_COMMAND___ to plan."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_lowercase_placeholder_not_matched(self):
|
||||
text = "Run __SPECKIT_COMMAND_plan__ to plan."
|
||||
assert IntegrationBase.resolve_command_refs(text, ".") == text
|
||||
|
||||
def test_placeholder_adjacent_to_text(self):
|
||||
text = "foo__SPECKIT_COMMAND_PLAN__bar"
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "foo/speckit.planbar"
|
||||
|
||||
def test_placeholder_with_digits(self):
|
||||
text = "__SPECKIT_COMMAND_V2_PLAN__"
|
||||
result = IntegrationBase.resolve_command_refs(text, ".")
|
||||
assert result == "/speckit.v2.plan"
|
||||
|
||||
|
||||
class TestResolvePythonInterpreter:
|
||||
def test_returns_python_on_path(self, monkeypatch):
|
||||
# Positive: when python3 is on PATH it is preferred over python.
|
||||
# Pin a POSIX platform so the Windows stub probe (tested separately
|
||||
# below) does not reject the fake PATH entries on Windows CI.
|
||||
def fake_which(name):
|
||||
return f"/usr/bin/{name}" if name in ("python3", "python") else None
|
||||
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", fake_which
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
def test_falls_back_to_python_when_no_python3(self, monkeypatch):
|
||||
def fake_which(name):
|
||||
return "/usr/bin/python" if name == "python" else None
|
||||
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", fake_which
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python"
|
||||
|
||||
def test_falls_back_to_sys_executable_when_nothing_found(self, monkeypatch):
|
||||
# Negative: nothing on PATH and no venv -> the running interpreter
|
||||
# (sys.executable) is used so the command works in this environment.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", "/opt/py/bin/python"
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "/opt/py/bin/python"
|
||||
|
||||
def test_falls_back_to_python3_when_no_interpreter_at_all(self, monkeypatch):
|
||||
# Negative edge: neither PATH nor sys.executable resolves.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", ""
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
def test_prefers_project_venv_posix(self, monkeypatch, tmp_path):
|
||||
venv_python = tmp_path / ".venv" / "bin" / "python"
|
||||
venv_python.parent.mkdir(parents=True)
|
||||
venv_python.write_text("")
|
||||
# Even if python3 is on PATH, the project venv wins. The returned
|
||||
# path is relative to the project root for portability.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3",
|
||||
)
|
||||
result = IntegrationBase.resolve_python_interpreter(tmp_path)
|
||||
assert result == ".venv/bin/python"
|
||||
|
||||
def test_prefers_project_venv_windows(self, monkeypatch, tmp_path):
|
||||
venv_python = tmp_path / ".venv" / "Scripts" / "python.exe"
|
||||
venv_python.parent.mkdir(parents=True)
|
||||
venv_python.write_text("")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
result = IntegrationBase.resolve_python_interpreter(tmp_path)
|
||||
assert result == ".venv/Scripts/python.exe"
|
||||
|
||||
def test_ignores_missing_venv(self, monkeypatch, tmp_path):
|
||||
# Negative: no venv directory -> PATH resolution is used instead.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter(tmp_path) == "python3"
|
||||
|
||||
def test_windows_skips_store_alias_stub(self, monkeypatch):
|
||||
# On Windows, python3 on PATH may be the Microsoft Store App
|
||||
# Execution Alias stub: it exists but only prints an installer
|
||||
# hint and exits non-zero. Existence is not enough; the
|
||||
# interpreter must actually run (mirrors #3304 for the CLI).
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: f"C:\\WindowsApps\\{name}.exe"
|
||||
if name in ("python3", "python")
|
||||
else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: False)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable", "C:\\Python\\python.exe"
|
||||
)
|
||||
result = IntegrationBase.resolve_python_interpreter()
|
||||
assert result == "C:\\Python\\python.exe"
|
||||
|
||||
def test_windows_keeps_working_interpreter(self, monkeypatch):
|
||||
# Positive: a real python3 on Windows PATH passes the run check.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: f"C:\\Python\\{name}.exe" if name == "python3" else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase, "_interpreter_runs", staticmethod(lambda path: True)
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
def test_windows_stub_python3_falls_through_to_working_python(self, monkeypatch):
|
||||
# python3 is the stub but python is a real install: pick python.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "win32")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: f"C:\\somewhere\\{name}.exe"
|
||||
if name in ("python3", "python")
|
||||
else None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase,
|
||||
"_interpreter_runs",
|
||||
staticmethod(lambda path: path.endswith("python.exe")),
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python"
|
||||
|
||||
def test_posix_does_not_spawn_run_check(self, monkeypatch):
|
||||
# Non-Windows platforms have no App Execution Alias; existence
|
||||
# on PATH stays sufficient and no subprocess is spawned.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
|
||||
def boom(path):
|
||||
raise AssertionError("run check must not execute on POSIX")
|
||||
|
||||
monkeypatch.setattr(
|
||||
IntegrationBase, "_interpreter_runs", staticmethod(boom)
|
||||
)
|
||||
assert IntegrationBase.resolve_python_interpreter() == "python3"
|
||||
|
||||
|
||||
class TestProcessTemplatePyScriptType:
|
||||
CONTENT = (
|
||||
"---\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/check-prerequisites.sh --json\n"
|
||||
" ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
|
||||
" py: scripts/python/check-prerequisites.py --json\n"
|
||||
"---\n"
|
||||
"Run {SCRIPT} now."
|
||||
)
|
||||
|
||||
def test_py_prefixes_interpreter(self, monkeypatch):
|
||||
# Positive: py script type prefixes a resolved interpreter and the
|
||||
# script path is rewritten to the .specify location.
|
||||
monkeypatch.setattr("specify_cli.integrations.base.sys.platform", "linux")
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert "python3 .specify/scripts/python/check-prerequisites.py --json" in result
|
||||
# The scripts: frontmatter block is stripped.
|
||||
assert "scripts:" not in result
|
||||
|
||||
def test_sh_does_not_prefix_interpreter(self):
|
||||
# Negative: non-py script types are never prefixed with an interpreter.
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "sh")
|
||||
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
|
||||
assert "python" not in result
|
||||
|
||||
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
|
||||
# An interpreter path containing whitespace (e.g. Windows
|
||||
# ``Program Files``) must be quoted so it isn't split into args.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which", lambda name: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.sys.executable",
|
||||
r"C:\Program Files\Python\python.exe",
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert (
|
||||
'"C:\\Program Files\\Python\\python.exe" '
|
||||
".specify/scripts/python/check-prerequisites.py --json"
|
||||
) in result
|
||||
|
||||
def test_py_does_not_quote_interpreter_without_spaces(self, monkeypatch):
|
||||
# Negative: a whitespace-free interpreter is left unquoted.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
|
||||
assert '"' not in result.split("check-prerequisites.py")[0]
|
||||
|
||||
def test_py_uses_project_venv(self, monkeypatch, tmp_path):
|
||||
venv_python = tmp_path / ".venv" / "bin" / "python"
|
||||
venv_python.parent.mkdir(parents=True)
|
||||
venv_python.write_text("")
|
||||
result = IntegrationBase.process_template(
|
||||
self.CONTENT, "agent", "py", project_root=tmp_path
|
||||
)
|
||||
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
|
||||
|
||||
|
||||
class TestInstallScriptsPython:
|
||||
def _make_integration_with_scripts(self, monkeypatch, tmp_path):
|
||||
scripts_src = tmp_path / "bundled_scripts"
|
||||
scripts_src.mkdir()
|
||||
(scripts_src / "common.py").write_text("print('hi')\n")
|
||||
(scripts_src / "common.sh").write_text("echo hi\n")
|
||||
(scripts_src / "notes.txt").write_text("not executable\n")
|
||||
integration = StubIntegration()
|
||||
monkeypatch.setattr(
|
||||
integration, "integration_scripts_dir", lambda: scripts_src
|
||||
)
|
||||
return integration
|
||||
|
||||
def test_copies_all_script_files(self, monkeypatch, tmp_path):
|
||||
# Cross-platform: every bundled file is copied into the project.
|
||||
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
manifest = IntegrationManifest("stub", project_root.resolve())
|
||||
|
||||
created = integration.install_scripts(project_root, manifest)
|
||||
names = {p.name for p in created}
|
||||
assert {"common.py", "common.sh", "notes.txt"} == names
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform == "win32", reason="chmod exec bit not reliable on Windows"
|
||||
)
|
||||
def test_marks_py_and_sh_executable(self, monkeypatch, tmp_path):
|
||||
integration = self._make_integration_with_scripts(monkeypatch, tmp_path)
|
||||
project_root = tmp_path / "proj"
|
||||
project_root.mkdir()
|
||||
manifest = IntegrationManifest("stub", project_root.resolve())
|
||||
|
||||
integration.install_scripts(project_root, manifest)
|
||||
|
||||
dest = project_root / ".specify" / "integrations" / "stub" / "scripts"
|
||||
py_file = dest / "common.py"
|
||||
sh_file = dest / "common.sh"
|
||||
txt_file = dest / "notes.txt"
|
||||
# Positive: .py and .sh are executable.
|
||||
assert py_file.stat().st_mode & 0o111
|
||||
assert sh_file.stat().st_mode & 0o111
|
||||
# Negative: a non-script file is not made executable.
|
||||
assert not (txt_file.stat().st_mode & 0o111)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,633 @@
|
||||
"""Tests for the per-integration `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` and
|
||||
`SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` env-var hooks.
|
||||
|
||||
The hooks are implemented in `IntegrationBase._apply_extra_args_env_var` and
|
||||
`IntegrationBase._resolve_executable` and wired into every concrete
|
||||
`build_exec_args` — `MarkdownIntegration`, `TomlIntegration`,
|
||||
`SkillsIntegration`, plus override integrations.
|
||||
These tests cover both the shared mechanisms (via `SkillsIntegration` stubs
|
||||
near the top of the file) and override integrations end-to-end (further down).
|
||||
See issues #2595 and #2596."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import (
|
||||
MarkdownIntegration,
|
||||
SkillsIntegration,
|
||||
TomlIntegration,
|
||||
)
|
||||
|
||||
|
||||
class _ClaudeStub(SkillsIntegration):
|
||||
"""Minimal Claude-like SkillsIntegration for testing."""
|
||||
|
||||
key = "claude"
|
||||
config = {
|
||||
"name": "Claude (test stub)",
|
||||
"folder": ".claude/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".claude/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
|
||||
|
||||
class _KiroCliStub(SkillsIntegration):
|
||||
"""SkillsIntegration with a hyphenated key to exercise key
|
||||
normalization (`kiro-cli` → `KIRO_CLI`)."""
|
||||
|
||||
key = "kiro-cli"
|
||||
config = {
|
||||
"name": "Kiro CLI (test stub)",
|
||||
"folder": ".kiro/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kiro/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
|
||||
class _NoCliStub(SkillsIntegration):
|
||||
"""SkillsIntegration with requires_cli=False — build_exec_args
|
||||
must return None and the env-var hook must not fire."""
|
||||
|
||||
key = "no-cli"
|
||||
config = {
|
||||
"name": "No-CLI agent (test stub)",
|
||||
"folder": ".no-cli/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".no-cli/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
|
||||
class _MarkdownAgentStub(MarkdownIntegration):
|
||||
"""Bare MarkdownIntegration subclass — does NOT override
|
||||
`build_exec_args`. Locks the base implementation in
|
||||
`MarkdownIntegration.build_exec_args` for the common case
|
||||
(most concrete integrations: Amp, Auggie, Generic, …)."""
|
||||
|
||||
key = "md-agent"
|
||||
config = {
|
||||
"name": "Markdown agent (test stub)",
|
||||
"folder": ".md-agent/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".md-agent/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
|
||||
class _TomlAgentStub(TomlIntegration):
|
||||
"""Bare TomlIntegration subclass — does NOT override
|
||||
`build_exec_args`. Locks the base implementation in
|
||||
`TomlIntegration.build_exec_args` (Gemini, Tabnine)."""
|
||||
|
||||
key = "toml-agent"
|
||||
config = {
|
||||
"name": "TOML agent (test stub)",
|
||||
"folder": ".toml-agent/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".toml-agent/commands",
|
||||
"format": "toml",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".toml",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_extra_args_env(monkeypatch):
|
||||
"""Strip any leaked SPECKIT_INTEGRATION_*_EXTRA_ARGS and
|
||||
SPECKIT_INTEGRATION_*_EXECUTABLE vars from the test env so a
|
||||
developer's shell setting doesn't pollute results."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith("SPECKIT_INTEGRATION_") and (
|
||||
key.endswith("_EXTRA_ARGS") or key.endswith("_EXECUTABLE")
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_env_var_unset_byte_identical_argv():
|
||||
"""Default behaviour: env var unset → no extra args inserted.
|
||||
|
||||
Locks the backward-compatibility guarantee that existing
|
||||
operators see no change.
|
||||
"""
|
||||
args = _ClaudeStub().build_exec_args("hello prompt")
|
||||
assert args == ["claude", "-p", "hello prompt", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_env_var_set_flag_inserted_before_model_and_output_format(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions"
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("hello prompt", model="sonnet")
|
||||
assert args == [
|
||||
"claude",
|
||||
"-p",
|
||||
"hello prompt",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_env_var_multi_token_parsed_via_shlex(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
|
||||
"--dangerously-skip-permissions --max-turns 3",
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"claude",
|
||||
"-p",
|
||||
"p",
|
||||
"--dangerously-skip-permissions",
|
||||
"--max-turns",
|
||||
"3",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_malformed_quoting_raises_actionable_value_error(monkeypatch):
|
||||
"""An unmatched quote in the env-var value must surface a clear
|
||||
error naming the offending env var and showing the invalid value,
|
||||
rather than crashing workflow dispatch with a bare shlex traceback."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
|
||||
'--flag "unterminated',
|
||||
)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
_ClaudeStub().build_exec_args("p")
|
||||
msg = str(excinfo.value)
|
||||
assert "SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS" in msg
|
||||
assert "--flag \"unterminated" in msg
|
||||
|
||||
|
||||
def test_env_var_empty_or_whitespace_is_noop(monkeypatch):
|
||||
"""An env var set to '' or ' ' is treated as unset."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", " ")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == ["claude", "-p", "p", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_other_integration_env_var_ignored(monkeypatch):
|
||||
"""`SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS` set must NOT leak into
|
||||
Claude's argv (per-integration scoping)."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS", "--gemini-only-flag")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == ["claude", "-p", "p", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_key_normalization_hyphen_to_underscore_uppercase(monkeypatch):
|
||||
"""`kiro-cli` key looks up `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`
|
||||
(hyphens replaced with underscores, then uppercased)."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--some-kiro-flag"
|
||||
)
|
||||
args = _KiroCliStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"kiro-cli",
|
||||
"-p",
|
||||
"p",
|
||||
"--some-kiro-flag",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_requires_cli_false_returns_none(monkeypatch):
|
||||
"""`requires_cli: False` short-circuits to None — the env-var
|
||||
hook is never reached and no argv is built."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXTRA_ARGS", "--should-not-appear")
|
||||
assert _NoCliStub().build_exec_args("p") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base-class coverage
|
||||
#
|
||||
# Most integrations inherit `build_exec_args` from `MarkdownIntegration`
|
||||
# or `TomlIntegration` without overriding it. The tests above use
|
||||
# `SkillsIntegration` stubs (which share the same hook mechanism) — these
|
||||
# tests exercise the two other base implementations directly so all three
|
||||
# concrete bases are covered.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_markdown_integration_base_honours_extra_args(monkeypatch):
|
||||
"""A bare `MarkdownIntegration` subclass — which does not override
|
||||
`build_exec_args` — must honour the env var via the base
|
||||
implementation. Covers the most common integration pattern."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_MD_AGENT_EXTRA_ARGS", "--debug --max-tokens 100"
|
||||
)
|
||||
args = _MarkdownAgentStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"md-agent",
|
||||
"-p",
|
||||
"p",
|
||||
"--debug",
|
||||
"--max-tokens",
|
||||
"100",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_toml_integration_base_honours_extra_args(monkeypatch):
|
||||
"""A bare `TomlIntegration` subclass — which does not override
|
||||
`build_exec_args` — must honour the env var via the base
|
||||
implementation. Covers Gemini/Tabnine-style integrations."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_TOML_AGENT_EXTRA_ARGS", "--yolo"
|
||||
)
|
||||
args = _TomlAgentStub().build_exec_args("p", model="gemini-pro")
|
||||
# TomlIntegration uses `-m` for model (vs Markdown's `--model`).
|
||||
assert args == [
|
||||
"toml-agent",
|
||||
"-p",
|
||||
"p",
|
||||
"--yolo",
|
||||
"-m",
|
||||
"gemini-pro",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override-integration coverage
|
||||
#
|
||||
# CodexIntegration, DevinIntegration, OpencodeIntegration and
|
||||
# CopilotIntegration each override `build_exec_args` rather than using the
|
||||
# base implementations. The env-var hook must be wired into every override
|
||||
# so the documented behaviour ("works for every requires_cli integration")
|
||||
# is honoured. These tests lock that contract per integration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_codex_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
|
||||
args = CodexIntegration().build_exec_args("p", model="gpt-5")
|
||||
assert args == [
|
||||
"codex",
|
||||
"exec",
|
||||
"p",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"--model",
|
||||
"gpt-5",
|
||||
"--json",
|
||||
]
|
||||
|
||||
|
||||
def test_devin_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXTRA_ARGS", "--no-confirm")
|
||||
args = DevinIntegration().build_exec_args("p")
|
||||
assert args == ["devin", "-p", "p", "--no-confirm"]
|
||||
|
||||
|
||||
def test_opencode_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--quiet")
|
||||
args = OpencodeIntegration().build_exec_args("p")
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--quiet",
|
||||
"--format",
|
||||
"json",
|
||||
"p",
|
||||
]
|
||||
|
||||
|
||||
def test_opencode_extra_args_cannot_clobber_prompt_derived_command(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Operator-injected extra args must appear BEFORE the prompt-derived
|
||||
``--command <X>`` so that Spec Kit's command selection wins under
|
||||
repeated-flag CLI semantics (last value typically takes precedence).
|
||||
|
||||
Locks against the regression where an operator setting
|
||||
``SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS="--command malicious"`` could redirect
|
||||
a slash-prefixed prompt to a different command.
|
||||
"""
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--command operator-override"
|
||||
)
|
||||
args = OpencodeIntegration().build_exec_args("/speckit body text")
|
||||
# Prompt-derived "--command speckit" appears AFTER the
|
||||
# operator-injected one, so a CLI that resolves repeated flags
|
||||
# last-wins will honour Spec Kit's choice.
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--command",
|
||||
"operator-override",
|
||||
"--command",
|
||||
"speckit",
|
||||
"--format",
|
||||
"json",
|
||||
"body text",
|
||||
]
|
||||
|
||||
|
||||
def test_copilot_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.copilot import (
|
||||
CopilotIntegration,
|
||||
_copilot_executable,
|
||||
)
|
||||
|
||||
# Disable --yolo so the argv shape stays deterministic.
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
|
||||
)
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
# `_copilot_executable()` returns "copilot.cmd" on Windows and
|
||||
# "copilot" elsewhere; the test must mirror that to stay portable.
|
||||
assert args == [
|
||||
_copilot_executable(),
|
||||
"-p",
|
||||
"p",
|
||||
"--allow-tool",
|
||||
"shell(echo)",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `dispatch_command` end-to-end coverage
|
||||
#
|
||||
# Workflow execution calls `impl.dispatch_command(...)`, not
|
||||
# `build_exec_args` directly. `IntegrationBase.dispatch_command` delegates
|
||||
# to `build_exec_args` (so the override fixes above flow through), but
|
||||
# `CopilotIntegration` overrides `dispatch_command` and constructs
|
||||
# `cli_args` inline — the hook must be invoked there too or the env var
|
||||
# is silently ignored at workflow runtime. These tests monkeypatch
|
||||
# `subprocess.run` and assert the env-var args reach the executed argv.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RunCapture:
|
||||
"""Test double that captures argv passed to subprocess.run."""
|
||||
|
||||
def __init__(self):
|
||||
self.captured_args: list[str] | None = None
|
||||
|
||||
def __call__(self, args, **kwargs):
|
||||
self.captured_args = list(args)
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return _Result()
|
||||
|
||||
|
||||
def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
|
||||
"""Locks the bypass fix: `CopilotIntegration.dispatch_command`
|
||||
must honour `SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS`, not just `build_exec_args`.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
|
||||
)
|
||||
|
||||
CopilotIntegration().dispatch_command(
|
||||
"speckit.plan", args="body", stream=False
|
||||
)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
# Hook inserted between `-p prompt` and the canonical Copilot flags.
|
||||
p_idx = capture.captured_args.index("-p")
|
||||
agent_idx = capture.captured_args.index("--agent")
|
||||
extra_idx = capture.captured_args.index("--allow-tool")
|
||||
assert p_idx < extra_idx < agent_idx
|
||||
assert "shell(echo)" in capture.captured_args
|
||||
|
||||
|
||||
def test_codex_dispatch_command_includes_extra_args(monkeypatch):
|
||||
"""Lock the inherited `IntegrationBase.dispatch_command` path:
|
||||
Codex (and by transitivity Devin, Opencode) flow through
|
||||
`build_exec_args`, so the env var must reach argv at workflow
|
||||
runtime.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
|
||||
|
||||
CodexIntegration().dispatch_command(
|
||||
"speckit.plan", args="body", stream=False
|
||||
)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
assert "--sandbox" in capture.captured_args
|
||||
assert "read-only" in capture.captured_args
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPECKIT_INTEGRATION_<KEY>_EXECUTABLE tests
|
||||
#
|
||||
# The `_resolve_executable()` method on `IntegrationBase` checks
|
||||
# `SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` and, when set, substitutes that
|
||||
# value for `self.key` as the first token in argv. The tests below lock
|
||||
# the behaviour across shared and override integration paths:
|
||||
# - the shared SkillsIntegration/MarkdownIntegration/TomlIntegration bases,
|
||||
# - representative override integrations,
|
||||
# - the hyphen→underscore key normalisation, and
|
||||
# - whitespace/unset no-op guarantee.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_executable_env_var_unset_uses_key():
|
||||
"""Default: no override → executable is the integration key."""
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_replaces_first_argv_token(monkeypatch):
|
||||
"""Setting the env var substitutes the executable name in argv."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude/bin/claude")
|
||||
args = _ClaudeStub().build_exec_args("hello")
|
||||
assert args[0] == "/opt/claude/bin/claude"
|
||||
assert args[1:] == ["-p", "hello", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_executable_env_var_whitespace_only_falls_back_to_key(monkeypatch):
|
||||
"""Whitespace-only value is treated as unset → falls back to self.key."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", " ")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_key_normalization_hyphen_to_underscore(monkeypatch):
|
||||
"""`kiro-cli` key maps to `SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE`."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE", "/usr/local/bin/kiro-cli")
|
||||
args = _KiroCliStub().build_exec_args("p")
|
||||
assert args[0] == "/usr/local/bin/kiro-cli"
|
||||
|
||||
|
||||
def test_executable_env_var_other_integration_ignored(monkeypatch):
|
||||
"""`SPECKIT_INTEGRATION_GEMINI_EXECUTABLE` must NOT affect Claude."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXECUTABLE", "/custom/gemini")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_markdown_integration(monkeypatch):
|
||||
"""MarkdownIntegration base honours the executable env var."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_MD_AGENT_EXECUTABLE", "/custom/md-agent")
|
||||
args = _MarkdownAgentStub().build_exec_args("p")
|
||||
assert args[0] == "/custom/md-agent"
|
||||
|
||||
|
||||
def test_executable_env_var_toml_integration(monkeypatch):
|
||||
"""TomlIntegration base honours the executable env var."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_TOML_AGENT_EXECUTABLE", "/custom/toml-agent")
|
||||
args = _TomlAgentStub().build_exec_args("p")
|
||||
assert args[0] == "/custom/toml-agent"
|
||||
|
||||
|
||||
def test_executable_env_var_requires_cli_false_returns_none(monkeypatch):
|
||||
"""`requires_cli: False` still returns None even when executable is set."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXECUTABLE", "/custom/no-cli")
|
||||
assert _NoCliStub().build_exec_args("p") is None
|
||||
|
||||
|
||||
def test_executable_env_var_codex_integration(monkeypatch):
|
||||
"""CodexIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXECUTABLE", "/opt/codex")
|
||||
args = CodexIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/codex"
|
||||
assert args[1] == "exec"
|
||||
|
||||
|
||||
def test_executable_env_var_devin_integration(monkeypatch):
|
||||
"""DevinIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXECUTABLE", "/opt/devin")
|
||||
args = DevinIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/devin"
|
||||
|
||||
|
||||
def test_executable_env_var_opencode_integration(monkeypatch):
|
||||
"""OpencodeIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXECUTABLE", "/opt/opencode")
|
||||
args = OpencodeIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/opencode"
|
||||
assert args[1] == "run"
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_integration(monkeypatch):
|
||||
"""CopilotIntegration honours the executable env var, overriding the
|
||||
platform-specific default from `_copilot_executable()`."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot")
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/copilot"
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_unset_uses_platform_default(monkeypatch):
|
||||
"""When `SPECKIT_INTEGRATION_COPILOT_EXECUTABLE` is unset, Copilot
|
||||
falls back to the platform-specific default from `_copilot_executable()`."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration, _copilot_executable
|
||||
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
assert args[0] == _copilot_executable()
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_dispatch_command(monkeypatch):
|
||||
"""CopilotIntegration.dispatch_command honours the executable env var."""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot")
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
|
||||
CopilotIntegration().dispatch_command("speckit.plan", args="body", stream=False)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
assert capture.captured_args[0] == "/opt/copilot"
|
||||
|
||||
|
||||
def test_executable_and_extra_args_both_honoured(monkeypatch):
|
||||
"""Both the executable override and extra args env vars can be set
|
||||
simultaneously — they are independent hooks."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions"
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("hello", model="sonnet")
|
||||
assert args == [
|
||||
"/opt/claude",
|
||||
"-p",
|
||||
"hello",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Regression tests for integration-test environment isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_integration_tests_use_tmp_home(tmp_path: Path) -> None:
|
||||
home = tmp_path / "home"
|
||||
|
||||
assert Path(os.environ["HOME"]) == home
|
||||
assert Path(os.environ["USERPROFILE"]) == home
|
||||
assert Path(os.environ["XDG_CACHE_HOME"]) == home / ".cache"
|
||||
assert Path(os.environ["XDG_CONFIG_HOME"]) == home / ".config"
|
||||
assert Path(os.environ["XDG_DATA_HOME"]) == home / ".local" / "share"
|
||||
|
||||
# Most integrations resolve the user home via Path.home() (e.g. Hermes,
|
||||
# catalog), so the isolation has to reach that API, not just the env vars.
|
||||
assert Path.home() == home
|
||||
|
||||
assert home.is_dir()
|
||||
assert (home / ".cache").is_dir()
|
||||
assert (home / ".config").is_dir()
|
||||
assert (home / ".local" / "share").is_dir()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for AgyIntegration (Antigravity)."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestAgyIntegration(SkillsIntegrationTests):
|
||||
KEY = "agy"
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout."""
|
||||
i = get_integration(self.KEY)
|
||||
skills_opts = [o for o in i.options() if o.name == "--skills"]
|
||||
assert len(skills_opts) == 0
|
||||
|
||||
def test_requires_cli_is_true(self):
|
||||
"""agy is a CLI tool; requires_cli must be True."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["requires_cli"] is True
|
||||
|
||||
def test_install_url_is_set(self):
|
||||
"""install_url must point to the official installation page."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["install_url"] == "https://antigravity.google/"
|
||||
|
||||
|
||||
class TestAgyInitFlow:
|
||||
"""--integration agy creates expected files."""
|
||||
|
||||
def test_integration_agy_creates_skills(self, tmp_path):
|
||||
"""--integration agy should create skills directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration agy failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_agy_setup_warning(self, tmp_path):
|
||||
"""Agy integration should print a warning about v1.20.5 requirement during setup."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
# Click >= 8.2 separates stdout and stderr natively
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj2"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "agy", "--script", "sh", "--ignore-agent-tools"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Warning: The .agents/ layout requires Antigravity v1.20.5 or newer" in result.stderr
|
||||
|
||||
|
||||
class TestAgyBuildExecArgs:
|
||||
"""agy non-interactive execution argument building."""
|
||||
|
||||
def test_build_exec_args_returns_print_command(self):
|
||||
"""build_exec_args should return ['agy', '--print', prompt]."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("describe my feature")
|
||||
assert result == ["agy", "--print", "describe my feature"]
|
||||
|
||||
def test_build_exec_args_ignores_model(self):
|
||||
"""agy does not support --model; model param must be ignored."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("my prompt", model="gemini-pro")
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
def test_build_exec_args_ignores_output_json(self):
|
||||
"""agy does not support JSON output; output_json param must be ignored."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("agy")
|
||||
result = i.build_exec_args("my prompt", output_json=False)
|
||||
assert result == ["agy", "--print", "my prompt"]
|
||||
|
||||
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_AGY_EXTRA_ARGS must be appended after the prompt.
|
||||
|
||||
agy previously skipped _apply_extra_args_env_var entirely, so the
|
||||
documented per-integration extra-args hook was silently ignored
|
||||
(same class as the merged cursor-agent fix #3265).
|
||||
"""
|
||||
from specify_cli.integrations import get_integration
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXTRA_ARGS", "--verbose")
|
||||
i = get_integration("agy")
|
||||
assert i.build_exec_args("my prompt") == [
|
||||
"agy", "--print", "my prompt", "--verbose",
|
||||
]
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
from specify_cli.integrations import get_integration
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_AGY_EXECUTABLE", "/custom/agy")
|
||||
i = get_integration("agy")
|
||||
assert i.build_exec_args("my prompt")[0] == "/custom/agy"
|
||||
|
||||
|
||||
class TestAgyHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected into hook sections."""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills with hook sections should contain the normalization note."""
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
i = get_integration("agy")
|
||||
m = IntegrationManifest("agy", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = AgyIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self):
|
||||
"""Injecting the note twice must not duplicate it."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = AgyIntegration._inject_hook_command_note(content)
|
||||
twice = AgyIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_preserves_indentation(self):
|
||||
"""The injected note must match the indentation of the target line."""
|
||||
from specify_cli.integrations.agy import AgyIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = AgyIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [ln for ln in lines if "replace dots" in ln][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for AmpIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestAmpIntegration(MarkdownIntegrationTests):
|
||||
KEY = "amp"
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".agents/commands"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for AuggieIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestAuggieIntegration(MarkdownIntegrationTests):
|
||||
KEY = "auggie"
|
||||
FOLDER = ".augment/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".augment/commands"
|
||||
@@ -0,0 +1,313 @@
|
||||
"""Reusable test mixin for standard MarkdownIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
||||
``MarkdownIntegrationTests``.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class MarkdownIntegrationTests:
|
||||
"""Mixin — set class-level constants and inherit these tests.
|
||||
|
||||
Required class attrs on subclass::
|
||||
|
||||
KEY: str — integration registry key
|
||||
FOLDER: str — e.g. ".claude/"
|
||||
COMMANDS_SUBDIR: str — e.g. "commands"
|
||||
REGISTRAR_DIR: str — e.g. ".claude/commands"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
def test_registered(self):
|
||||
assert self.KEY in INTEGRATION_REGISTRY
|
||||
assert get_integration(self.KEY) is not None
|
||||
|
||||
def test_is_markdown_integration(self):
|
||||
assert isinstance(get_integration(self.KEY), MarkdownIntegration)
|
||||
|
||||
# -- Config -----------------------------------------------------------
|
||||
|
||||
def test_config_folder(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["folder"] == self.FOLDER
|
||||
|
||||
def test_config_commands_subdir(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
|
||||
|
||||
def test_registrar_config(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "markdown"
|
||||
assert i.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert i.registrar_config["extension"] == ".md"
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
expected_dir = i.commands_dest(tmp_path)
|
||||
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_templates_are_processed(self, tmp_path):
|
||||
"""Command files must have placeholders replaced, not raw templates."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0
|
||||
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}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan command must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
|
||||
assert plan_file.exists(), f"Plan file {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
|
||||
)
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"promote-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
|
||||
commands = sorted(cmd_dir.glob("speckit.*"))
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Build the expected file list for this integration + script variant."""
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.registrar_config["dir"]
|
||||
files = []
|
||||
|
||||
# Command files
|
||||
for stem in self.COMMAND_STEMS:
|
||||
files.append(f"{cmd_dir}/speckit.{stem}.md")
|
||||
|
||||
# Framework files
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
|
||||
"setup-plan.sh", "setup-tasks.sh"]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
|
||||
"setup-plan.ps1", "setup-tasks.ps1"]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in ["checklist-template.md",
|
||||
"constitution-template.md", "plan-template.md",
|
||||
"spec-template.md", "tasks-template.md"]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
|
||||
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], 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() and ".git" not in p.parts)
|
||||
expected = self._expected_files("sh")
|
||||
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 <key> --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||
"--ignore-agent-tools",
|
||||
], 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() and ".git" not in p.parts)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
@@ -0,0 +1,488 @@
|
||||
"""Reusable test mixin for standard SkillsIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
||||
``SkillsIntegrationTests``.
|
||||
|
||||
Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely,
|
||||
adapted for the ``speckit-<name>/SKILL.md`` skills layout.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import SkillsIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class SkillsIntegrationTests:
|
||||
"""Mixin — set class-level constants and inherit these tests.
|
||||
|
||||
Required class attrs on subclass::
|
||||
|
||||
KEY: str — integration registry key
|
||||
FOLDER: str — e.g. ".agents/"
|
||||
COMMANDS_SUBDIR: str — e.g. "skills"
|
||||
REGISTRAR_DIR: str — e.g. ".agents/skills"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
def test_registered(self):
|
||||
assert self.KEY in INTEGRATION_REGISTRY
|
||||
assert get_integration(self.KEY) is not None
|
||||
|
||||
def test_is_skills_integration(self):
|
||||
assert isinstance(get_integration(self.KEY), SkillsIntegration)
|
||||
|
||||
# -- Config -----------------------------------------------------------
|
||||
|
||||
def test_config_folder(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["folder"] == self.FOLDER
|
||||
|
||||
def test_config_commands_subdir(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
|
||||
|
||||
def test_registrar_config(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "markdown"
|
||||
assert i.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert i.registrar_config["extension"] == "/SKILL.md"
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in skill_files:
|
||||
assert f.exists()
|
||||
assert f.name == "SKILL.md"
|
||||
assert f.parent.name.startswith("speckit-")
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
expected_dir = i.skills_dest(tmp_path)
|
||||
assert expected_dir.exists(), f"Expected directory {expected_dir} was not created"
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(skill_files) > 0, "No skill files were created"
|
||||
for f in skill_files:
|
||||
# Each SKILL.md is in speckit-<name>/ under the skills directory
|
||||
assert f.resolve().parent.parent == expected_dir.resolve(), (
|
||||
f"{f} is not under {expected_dir}"
|
||||
)
|
||||
|
||||
def test_skill_directory_structure(self, tmp_path):
|
||||
"""Each command produces speckit-<name>/SKILL.md."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
expected_commands = {
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
|
||||
# Derive command names from the skill directory names
|
||||
actual_commands = set()
|
||||
for f in skill_files:
|
||||
skill_dir_name = f.parent.name # e.g. "speckit-plan"
|
||||
assert skill_dir_name.startswith("speckit-")
|
||||
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
|
||||
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_skill_frontmatter_structure(self, tmp_path):
|
||||
"""SKILL.md must have name, description, compatibility, metadata."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "name" in fm, f"{f} frontmatter missing 'name'"
|
||||
assert "description" in fm, f"{f} frontmatter missing 'description'"
|
||||
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
|
||||
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
|
||||
assert fm["metadata"]["author"] == "github-spec-kit"
|
||||
assert "source" in fm["metadata"]
|
||||
|
||||
def test_skill_uses_template_descriptions(self, tmp_path):
|
||||
"""SKILL.md should use the original template description for ZIP parity."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
# Description must be a non-empty string (from the template)
|
||||
assert isinstance(fm["description"], str)
|
||||
assert len(fm["description"]) > 0, f"{f} has empty description"
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
"""Skill body must have placeholders replaced, not raw templates."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_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}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_command_refs_use_hyphen_separator(self, tmp_path):
|
||||
"""Skills agents must resolve command refs with hyphen separator."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# Skills agents must use /speckit-<name>, not /speckit.<name>
|
||||
assert "/speckit." not in content, (
|
||||
f"{f.name} contains dot-notation /speckit. reference; "
|
||||
f"skills agents must use /speckit-<name>"
|
||||
)
|
||||
|
||||
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||
"""Generated skills with hook sections must explain dotted command conversion."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
specify_skill = i.skills_dest(tmp_path) / "speckit-specify" / "SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should explain dotted hook command conversion"
|
||||
)
|
||||
assert content.count("replace dots") == content.count(
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
|
||||
def test_hook_note_injected_for_each_instruction_independently(self):
|
||||
"""Existing hook notes should not suppress later missing notes."""
|
||||
content = (
|
||||
"---\n"
|
||||
"name: test\n"
|
||||
"---\n\n"
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
"- For each executable hook, output the following first block:\n"
|
||||
"\n"
|
||||
"- For each executable hook, output the following second block:\n"
|
||||
)
|
||||
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
|
||||
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||
|
||||
def test_skill_body_has_content(self, tmp_path):
|
||||
"""Each SKILL.md body should contain template content after the frontmatter."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
# Body is everything after the second ---
|
||||
parts = content.split("---", 2)
|
||||
body = parts[2].strip() if len(parts) >= 3 else ""
|
||||
assert len(body) > 0, f"{f} has empty body"
|
||||
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan skill must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan skill must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists(), f"Plan skill {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
|
||||
)
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
def test_pre_existing_skills_not_removed(self, tmp_path):
|
||||
"""Pre-existing non-speckit skills should be left untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
skills_dir = i.skills_dest(tmp_path)
|
||||
foreign_dir = skills_dir / "other-tool"
|
||||
foreign_dir.mkdir(parents=True)
|
||||
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
|
||||
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
|
||||
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"promote-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
skills_dir = i.skills_dest(project)
|
||||
assert skills_dir.is_dir(), f"--integration {self.KEY} did not create skills directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
skills_dir = i.skills_dest(project)
|
||||
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
|
||||
|
||||
def test_init_does_not_create_agent_context_config(self, tmp_path):
|
||||
"""agent-context is opt-in: init must not auto-install the extension
|
||||
or write its config."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"opts-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
assert not ext_cfg_path.exists()
|
||||
|
||||
# -- IntegrationOption ------------------------------------------------
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
i = get_integration(self.KEY)
|
||||
opts = i.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 1
|
||||
assert skills_opts[0].is_flag is True
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Build the full expected file list for a given script variant."""
|
||||
i = get_integration(self.KEY)
|
||||
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
|
||||
|
||||
files = []
|
||||
# Skill files (core commands)
|
||||
for cmd in self._SKILL_COMMANDS:
|
||||
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
|
||||
# Integration metadata
|
||||
files += [
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
f".specify/integrations/{self.KEY}.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/memory/constitution.md",
|
||||
]
|
||||
# Script variant
|
||||
if script_variant == "sh":
|
||||
files += [
|
||||
".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/setup-tasks.sh",
|
||||
]
|
||||
else:
|
||||
files += [
|
||||
".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/setup-tasks.ps1",
|
||||
]
|
||||
# Templates
|
||||
files += [
|
||||
".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",
|
||||
]
|
||||
# Bundled workflow
|
||||
files += [
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
]
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], 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() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
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 <key> --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "ps",
|
||||
"--ignore-agent-tools",
|
||||
], 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() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
@@ -0,0 +1,597 @@
|
||||
"""Reusable test mixin for standard TomlIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
||||
``TomlIntegrationTests``.
|
||||
|
||||
Mirrors ``MarkdownIntegrationTests`` closely — same test structure,
|
||||
adapted for TOML output format.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import TomlIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TomlIntegrationTests:
|
||||
"""Mixin — set class-level constants and inherit these tests.
|
||||
|
||||
Required class attrs on subclass::
|
||||
|
||||
KEY: str — integration registry key
|
||||
FOLDER: str — e.g. ".gemini/"
|
||||
COMMANDS_SUBDIR: str — e.g. "commands"
|
||||
REGISTRAR_DIR: str — e.g. ".gemini/commands"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
def test_registered(self):
|
||||
assert self.KEY in INTEGRATION_REGISTRY
|
||||
assert get_integration(self.KEY) is not None
|
||||
|
||||
def test_is_toml_integration(self):
|
||||
assert isinstance(get_integration(self.KEY), TomlIntegration)
|
||||
|
||||
# -- Config -----------------------------------------------------------
|
||||
|
||||
def test_config_folder(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["folder"] == self.FOLDER
|
||||
|
||||
def test_config_commands_subdir(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
|
||||
|
||||
def test_registrar_config(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "toml"
|
||||
assert i.registrar_config["args"] == "{{args}}"
|
||||
assert i.registrar_config["extension"] == ".toml"
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".toml")
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
expected_dir = i.commands_dest(tmp_path)
|
||||
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_templates_are_processed(self, tmp_path):
|
||||
"""Command files must have placeholders replaced and be valid TOML."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0
|
||||
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}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_toml_has_description(self, tmp_path):
|
||||
"""Every TOML command file should have a description key."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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 'description = "' in content, f"{f.name} missing description key"
|
||||
|
||||
def test_toml_has_prompt(self, tmp_path):
|
||||
"""Every TOML command file should have a prompt key."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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 "prompt = " in content, f"{f.name} missing prompt key"
|
||||
|
||||
def test_toml_uses_correct_arg_placeholder(self, tmp_path):
|
||||
"""TOML commands must use {{args}} (from {ARGS} replacement)."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
# At least one file should contain {{args}} from the {ARGS} placeholder
|
||||
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
|
||||
assert has_args, "No TOML command file contains {{args}} placeholder"
|
||||
has_dollar_args = any(
|
||||
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
|
||||
)
|
||||
assert not has_dollar_args, (
|
||||
"TOML command still contains $ARGUMENTS instead of {{args}}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frontmatter", "expected"),
|
||||
[
|
||||
(
|
||||
"---\ndescription: |\n First line\n Second line\n---\nBody\n",
|
||||
"First line\nSecond line\n",
|
||||
),
|
||||
(
|
||||
"---\ndescription: >\n First line\n Second line\n---\nBody\n",
|
||||
"First line Second line\n",
|
||||
),
|
||||
(
|
||||
"---\ndescription: |-\n First line\n Second line\n---\nBody\n",
|
||||
"First line\nSecond line",
|
||||
),
|
||||
(
|
||||
"---\ndescription: >-\n First line\n Second line\n---\nBody\n",
|
||||
"First line Second line",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_toml_extract_description_supports_block_scalars(
|
||||
self, frontmatter, expected
|
||||
):
|
||||
assert TomlIntegration._extract_description(frontmatter) == expected
|
||||
|
||||
def test_split_frontmatter_ignores_indented_delimiters(self):
|
||||
content = "---\ndescription: |\n line one\n ---\n line two\n---\nBody\n"
|
||||
|
||||
frontmatter, body = TomlIntegration._split_frontmatter(content)
|
||||
|
||||
assert "line two" in frontmatter
|
||||
assert body == "Body\n"
|
||||
|
||||
def test_toml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch):
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Summary line one\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/example.sh\n"
|
||||
"---\n"
|
||||
"Body line one\n"
|
||||
"Body line two\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) == 1
|
||||
|
||||
generated = cmd_files[0].read_text(encoding="utf-8")
|
||||
parsed = tomllib.loads(generated)
|
||||
|
||||
assert parsed["description"] == "Summary line one"
|
||||
assert parsed["prompt"] == "Body line one\nBody line two"
|
||||
assert "description:" not in parsed["prompt"]
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_toml_no_ambiguous_closing_quotes(self, tmp_path, monkeypatch):
|
||||
"""Multiline body ending with a double quote must not produce an ambiguous TOML multiline-string closing delimiter (#2113)."""
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Test\n"
|
||||
"scripts:\n"
|
||||
" sh: echo ok\n"
|
||||
"---\n"
|
||||
"Check the following:\n"
|
||||
'- Correct: "Is X clearly specified?"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) == 1
|
||||
|
||||
raw = cmd_files[0].read_text(encoding="utf-8")
|
||||
assert '""""' not in raw, "closing delimiter must not merge with body quote"
|
||||
assert '"""\n' in raw, "body must use multiline basic string"
|
||||
parsed = tomllib.loads(raw)
|
||||
assert parsed["prompt"].endswith('specified?"')
|
||||
assert not parsed["prompt"].endswith("\n"), (
|
||||
"parsed value must not gain a trailing newline"
|
||||
)
|
||||
|
||||
def test_toml_triple_double_and_single_quote_ending(self, tmp_path, monkeypatch):
|
||||
"""Body containing `\"\"\"` and ending with `'` falls back to escaped basic string."""
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Test\n"
|
||||
"scripts:\n"
|
||||
" sh: echo ok\n"
|
||||
"---\n"
|
||||
'Use """triple""" quotes\n'
|
||||
"and end with 'single'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) == 1
|
||||
|
||||
raw = cmd_files[0].read_text(encoding="utf-8")
|
||||
assert "''''" not in raw, (
|
||||
"literal string must not produce ambiguous closing quotes"
|
||||
)
|
||||
parsed = tomllib.loads(raw)
|
||||
assert parsed["prompt"].endswith("'single'")
|
||||
assert '"""triple"""' in parsed["prompt"]
|
||||
assert not parsed["prompt"].endswith("\n"), (
|
||||
"parsed value must not gain a trailing newline"
|
||||
)
|
||||
|
||||
def test_toml_closing_delimiter_inline_when_safe(self, tmp_path, monkeypatch):
|
||||
"""Body NOT ending with `"` keeps closing `\"\"\"` inline (no extra newline)."""
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Test\n"
|
||||
"scripts:\n"
|
||||
" sh: echo ok\n"
|
||||
"---\n"
|
||||
"Line one\n"
|
||||
"Plain body content\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) == 1
|
||||
|
||||
raw = cmd_files[0].read_text(encoding="utf-8")
|
||||
parsed = tomllib.loads(raw)
|
||||
assert parsed["prompt"] == "Line one\nPlain body content"
|
||||
assert raw.rstrip().endswith('content"""'), (
|
||||
"closing delimiter should be inline when body does not end with a quote"
|
||||
)
|
||||
|
||||
def test_toml_string_escapes_control_characters(self):
|
||||
"""A value with control chars / a bare CR must render as parseable TOML.
|
||||
|
||||
TOML forbids literal control characters (U+0000–U+001F except tab and
|
||||
newline, plus U+007F) in every string form, and a bare CR that is not
|
||||
part of a CRLF pair. The renderer used to emit these raw into a basic or
|
||||
``\"\"\"`` multiline string, producing a config file that fails to parse."""
|
||||
value = "start\x00null\x01ctrl\x1besc\x7fdel\rlone-cr end"
|
||||
rendered = TomlIntegration._render_toml_string(value)
|
||||
parsed = tomllib.loads(f"prompt = {rendered}")
|
||||
assert parsed["prompt"] == value
|
||||
|
||||
def test_toml_is_valid(self, tmp_path):
|
||||
"""Every generated TOML file must parse without errors."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
raw = f.read_bytes()
|
||||
try:
|
||||
parsed = tomllib.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc
|
||||
assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key"
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan command must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
|
||||
assert plan_file.exists(), f"Plan file {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
|
||||
)
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"promote-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, (
|
||||
f"init --integration {self.KEY} failed: {result.output}"
|
||||
)
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
|
||||
commands = sorted(cmd_dir.glob("speckit.*.toml"))
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"analyze",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"converge",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Build the expected file list for this integration + script variant."""
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.registrar_config["dir"]
|
||||
files = []
|
||||
|
||||
# Command files (.toml)
|
||||
for stem in self.COMMAND_STEMS:
|
||||
files.append(f"{cmd_dir}/speckit.{stem}.toml")
|
||||
|
||||
# Framework files
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in [
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in [
|
||||
"checklist-template.md",
|
||||
"constitution-template.md",
|
||||
"plan-template.md",
|
||||
"spec-template.md",
|
||||
"tasks-template.md",
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
|
||||
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
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() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
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 <key> --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
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() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
@@ -0,0 +1,481 @@
|
||||
"""Reusable test mixin for standard YamlIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
||||
``YamlIntegrationTests``.
|
||||
|
||||
Mirrors ``TomlIntegrationTests`` closely — same test structure,
|
||||
adapted for YAML recipe output format.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import YamlIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class YamlIntegrationTests:
|
||||
"""Mixin — set class-level constants and inherit these tests.
|
||||
|
||||
Required class attrs on subclass::
|
||||
|
||||
KEY: str — integration registry key
|
||||
FOLDER: str — e.g. ".goose/"
|
||||
COMMANDS_SUBDIR: str — e.g. "recipes"
|
||||
REGISTRAR_DIR: str — e.g. ".goose/recipes"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
def test_registered(self):
|
||||
assert self.KEY in INTEGRATION_REGISTRY
|
||||
assert get_integration(self.KEY) is not None
|
||||
|
||||
def test_is_yaml_integration(self):
|
||||
assert isinstance(get_integration(self.KEY), YamlIntegration)
|
||||
|
||||
# -- Config -----------------------------------------------------------
|
||||
|
||||
def test_config_folder(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["folder"] == self.FOLDER
|
||||
|
||||
def test_config_commands_subdir(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.config["commands_subdir"] == self.COMMANDS_SUBDIR
|
||||
|
||||
def test_registrar_config(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "yaml"
|
||||
assert i.registrar_config["args"] == "{{args}}"
|
||||
assert i.registrar_config["extension"] == ".yaml"
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".yaml")
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
expected_dir = i.commands_dest(tmp_path)
|
||||
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_templates_are_processed(self, tmp_path):
|
||||
"""Command files must have placeholders replaced."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) > 0
|
||||
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}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_yaml_has_title(self, tmp_path):
|
||||
"""Every YAML recipe should have a title field."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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 "title:" in content, f"{f.name} missing title field"
|
||||
|
||||
def test_yaml_has_prompt(self, tmp_path):
|
||||
"""Every YAML recipe should have a prompt block scalar."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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 "prompt: |" in content, f"{f.name} missing prompt block scalar"
|
||||
|
||||
def test_yaml_uses_correct_arg_placeholder(self, tmp_path):
|
||||
"""YAML recipes must use {{args}} placeholder."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
has_args = any("{{args}}" in f.read_text(encoding="utf-8") for f in cmd_files)
|
||||
assert has_args, "No YAML recipe contains {{args}} placeholder"
|
||||
has_dollar_args = any(
|
||||
"$ARGUMENTS" in f.read_text(encoding="utf-8") for f in cmd_files
|
||||
)
|
||||
assert not has_dollar_args, (
|
||||
"YAML recipe still contains $ARGUMENTS instead of {{args}}"
|
||||
)
|
||||
|
||||
def test_yaml_is_valid(self, tmp_path):
|
||||
"""Every generated YAML file must parse without errors."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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")
|
||||
# Strip trailing source comment before parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
|
||||
try:
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
except Exception as exc:
|
||||
raise AssertionError(f"{f.name} is not valid YAML: {exc}") from exc
|
||||
assert "prompt" in parsed, f"{f.name} parsed YAML has no 'prompt' key"
|
||||
assert "title" in parsed, f"{f.name} parsed YAML has no 'title' key"
|
||||
|
||||
def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch):
|
||||
i = get_integration(self.KEY)
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: Summary line one\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/example.sh\n"
|
||||
"---\n"
|
||||
"Body line one\n"
|
||||
"Body line two\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
assert len(cmd_files) == 1
|
||||
|
||||
content = cmd_files[0].read_text(encoding="utf-8")
|
||||
# Strip source comment for parsing
|
||||
lines = content.split("\n")
|
||||
yaml_lines = [ln for ln in lines if not ln.startswith("# Source:")]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
|
||||
assert "description:" not in parsed["prompt"]
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_yaml_prompt_with_indented_first_line_stays_valid(self):
|
||||
"""A body whose first line is indented must still parse.
|
||||
|
||||
A bare ``|`` block scalar infers its indentation from the first
|
||||
non-empty line, so a body starting with an indented line (e.g. a
|
||||
markdown code block or nested list item) made the parser expect that
|
||||
deeper indent for the whole block and reject the later, shallower
|
||||
lines. The explicit ``|2`` indicator pins the indent so it parses."""
|
||||
body = " indented first line\nback to normal\n indented again"
|
||||
rendered = YamlIntegration._render_yaml("Title", "Desc", body, "src")
|
||||
|
||||
yaml_lines = [
|
||||
ln for ln in rendered.split("\n") if not ln.startswith("# Source:")
|
||||
]
|
||||
parsed = yaml.safe_load("\n".join(yaml_lines))
|
||||
assert parsed["prompt"].rstrip("\n") == body
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan command must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
|
||||
assert plan_file.exists(), f"Plan file {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
|
||||
)
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
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(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
def test_integration_flag_auto_promotes(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"promote-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init --integration {self.KEY} failed: {result.output}"
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"--integration {self.KEY} did not create commands directory"
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, (
|
||||
f"init --integration {self.KEY} failed: {result.output}"
|
||||
)
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir(), f"Commands directory {cmd_dir} not created"
|
||||
commands = sorted(cmd_dir.glob("speckit.*.yaml"))
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"analyze",
|
||||
"clarify",
|
||||
"constitution",
|
||||
"converge",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
]
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Build the expected file list for this integration + script variant."""
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.registrar_config["dir"]
|
||||
files = []
|
||||
|
||||
# Command files (.yaml)
|
||||
for stem in self.COMMAND_STEMS:
|
||||
files.append(f"{cmd_dir}/speckit.{stem}.yaml")
|
||||
|
||||
# Framework files
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in [
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in [
|
||||
"checklist-template.md",
|
||||
"constitution-template.md",
|
||||
"plan-template.md",
|
||||
"spec-template.md",
|
||||
"tasks-template.md",
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
|
||||
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration <key> --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
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() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("sh")
|
||||
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 <key> --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"ps",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
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() and ".git" not in p.parts
|
||||
)
|
||||
expected = self._expected_files("ps")
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for BobIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestBobIntegration(MarkdownIntegrationTests):
|
||||
KEY = "bob"
|
||||
FOLDER = ".bob/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".bob/commands"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,869 @@
|
||||
"""Tests for ClaudeIntegration."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
|
||||
from specify_cli.integrations.claude import ARGUMENT_HINTS, FORK_CONTEXT_COMMANDS
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TestClaudeIntegration:
|
||||
def test_registered(self):
|
||||
assert "claude" in INTEGRATION_REGISTRY
|
||||
assert get_integration("claude") is not None
|
||||
|
||||
def test_is_base_integration(self):
|
||||
assert isinstance(get_integration("claude"), IntegrationBase)
|
||||
|
||||
def test_config_uses_skills(self):
|
||||
integration = get_integration("claude")
|
||||
assert integration.config["folder"] == ".claude/"
|
||||
assert integration.config["commands_subdir"] == "skills"
|
||||
|
||||
def test_registrar_config_uses_skill_layout(self):
|
||||
integration = get_integration("claude")
|
||||
assert integration.registrar_config["dir"] == ".claude/skills"
|
||||
assert integration.registrar_config["format"] == "markdown"
|
||||
assert integration.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert integration.registrar_config["extension"] == "/SKILL.md"
|
||||
|
||||
def test_setup_creates_skill_files(self, tmp_path):
|
||||
integration = get_integration("claude")
|
||||
manifest = IntegrationManifest("claude", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
skill_files = [path for path in created if path.name == "SKILL.md"]
|
||||
assert skill_files
|
||||
|
||||
skills_dir = tmp_path / ".claude" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
|
||||
assert plan_skill.exists()
|
||||
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "/speckit." not in content, "skills agent must use /speckit-<name> not /speckit.<name>"
|
||||
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["name"] == "speckit-plan"
|
||||
assert parsed["user-invocable"] is True
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
|
||||
|
||||
def test_render_skill_unicode(self):
|
||||
"""Test rendering a skill preserves non-ASCII characters."""
|
||||
integration = get_integration("claude")
|
||||
rendered = integration._render_skill(
|
||||
"constitution",
|
||||
{"description": "Prüfe Konformität der Implementierung"},
|
||||
"Body",
|
||||
)
|
||||
assert "Prüfe Konformität" in rendered
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""The CLI no longer manages the agent context file — that is owned by
|
||||
the opt-in agent-context extension. Setup must not create or touch it."""
|
||||
integration = get_integration("claude")
|
||||
manifest = IntegrationManifest("claude", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
|
||||
"""A user-authored context file is left intact on teardown."""
|
||||
integration = get_integration("claude")
|
||||
ctx_path = tmp_path / "CLAUDE.md"
|
||||
original = "# CLAUDE.md\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
|
||||
manifest = IntegrationManifest("claude", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
integration.teardown(tmp_path, manifest)
|
||||
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "claude-promote"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"claude",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert not (project / ".claude" / "commands").exists()
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert init_options["ai"] == "claude"
|
||||
assert init_options["ai_skills"] is True
|
||||
assert init_options["integration"] == "claude"
|
||||
|
||||
def test_integration_flag_creates_skill_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "claude-integration"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"claude",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
||||
|
||||
def test_interactive_claude_selection_uses_integration_path(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "claude-interactive"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("specify_cli.commands.init._stdin_is_interactive", return_value=True),
|
||||
patch("specify_cli.commands.init.select_with_arrows", return_value="claude"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".specify" / "integration.json").exists()
|
||||
assert (project / ".specify" / "integrations" / "claude.manifest.json").exists()
|
||||
|
||||
skill_file = project / ".claude" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
skill_content = skill_file.read_text(encoding="utf-8")
|
||||
assert "user-invocable: true" in skill_content
|
||||
assert "disable-model-invocation: false" in skill_content
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert init_options["ai"] == "claude"
|
||||
assert init_options["ai_skills"] is True
|
||||
assert init_options["integration"] == "claude"
|
||||
|
||||
def test_claude_init_remains_usable_when_converter_fails(self, tmp_path):
|
||||
"""Claude init should succeed even without install_skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "fail-proj"
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--integration", "claude", "--script", "sh", "--ignore-agent-tools"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (target / ".claude" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
def test_claude_hooks_render_skill_invocation(self, tmp_path):
|
||||
from specify_cli.extensions import HookExecutor
|
||||
|
||||
project = tmp_path / "claude-hooks"
|
||||
project.mkdir()
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "claude", "ai_skills": True}))
|
||||
|
||||
hook_executor = HookExecutor(project)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_plan",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.plan",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "Executing: `/speckit-plan`" in message
|
||||
assert "EXECUTE_COMMAND: speckit.plan" in message
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
|
||||
|
||||
def test_claude_preset_creates_new_skill_without_commands_dir(self, tmp_path):
|
||||
from specify_cli import save_init_options
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
project = tmp_path / "claude-preset-skill"
|
||||
project.mkdir()
|
||||
save_init_options(project, {"ai": "claude", "ai_skills": True, "script": "sh"})
|
||||
|
||||
skills_dir = project / ".claude" / "skills"
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preset_dir = tmp_path / "claude-skill-command"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.research.md").write_text(
|
||||
"---\n"
|
||||
"description: Research workflow\n"
|
||||
"---\n\n"
|
||||
"preset:claude-skill-command\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "claude-skill-command",
|
||||
"name": "Claude Skill Command",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.research",
|
||||
"file": "commands/speckit.research.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-research" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "preset:claude-skill-command" in content
|
||||
assert "name: speckit-research" in content
|
||||
assert "user-invocable: true" in content
|
||||
assert "disable-model-invocation: false" in content
|
||||
|
||||
metadata = manager.registry.get("claude-skill-command")
|
||||
assert "speckit-research" in metadata.get("registered_skills", [])
|
||||
|
||||
|
||||
class TestClaudeArgumentHints:
|
||||
"""Verify that argument-hint frontmatter is injected for Claude skills."""
|
||||
|
||||
def test_converge_has_no_argument_hint(self):
|
||||
"""Converge should not advertise unsupported feature-name arguments."""
|
||||
assert "converge" not in ARGUMENT_HINTS
|
||||
|
||||
def test_all_skills_have_hints(self, tmp_path):
|
||||
"""Every skill with a configured hint must contain an argument-hint line."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
content = f.read_text(encoding="utf-8")
|
||||
if stem in ARGUMENT_HINTS:
|
||||
assert "argument-hint:" in content, (
|
||||
f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
|
||||
)
|
||||
else:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
|
||||
def test_hints_match_expected_values(self, tmp_path):
|
||||
"""Each skill's argument-hint must match the expected text."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
# Extract stem: speckit-plan -> plan
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
expected_hint = ARGUMENT_HINTS.get(stem)
|
||||
content = f.read_text(encoding="utf-8")
|
||||
if expected_hint is None:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
else:
|
||||
assert f'argument-hint: "{expected_hint}"' in content, (
|
||||
f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
|
||||
)
|
||||
|
||||
def test_hint_is_inside_frontmatter(self, tmp_path):
|
||||
"""argument-hint must appear between the --- delimiters, not in the body."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
|
||||
frontmatter = parts[1]
|
||||
body = parts[2]
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
if stem in ARGUMENT_HINTS:
|
||||
assert "argument-hint:" in frontmatter, (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
|
||||
)
|
||||
assert "argument-hint:" not in body, (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
|
||||
)
|
||||
else:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
|
||||
def test_hint_appears_after_description(self, tmp_path):
|
||||
"""argument-hint must immediately follow the description line."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
if stem not in ARGUMENT_HINTS:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
continue
|
||||
found_description = False
|
||||
for idx, line in enumerate(lines):
|
||||
if line.startswith("description:"):
|
||||
found_description = True
|
||||
assert idx + 1 < len(lines), (
|
||||
f"{f.parent.name}/SKILL.md: description is last line"
|
||||
)
|
||||
assert lines[idx + 1].startswith("argument-hint:"), (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
|
||||
)
|
||||
break
|
||||
assert found_description, (
|
||||
f"{f.parent.name}/SKILL.md: no description: line found in output"
|
||||
)
|
||||
|
||||
def test_inject_argument_hint_only_in_frontmatter(self):
|
||||
"""inject_argument_hint must not modify description: lines in the body."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\n"
|
||||
"description: My command\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"description: this is body text\n"
|
||||
)
|
||||
result = ClaudeIntegration.inject_argument_hint(content, "Test hint")
|
||||
lines = result.splitlines()
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1, (
|
||||
f"Expected exactly 1 argument-hint line, found {hint_count}"
|
||||
)
|
||||
|
||||
def test_inject_argument_hint_skips_if_already_present(self):
|
||||
"""inject_argument_hint must not duplicate if argument-hint already exists."""
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
content = (
|
||||
"---\n"
|
||||
"description: My command\n"
|
||||
'argument-hint: "Existing hint"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"Body text\n"
|
||||
)
|
||||
result = ClaudeIntegration.inject_argument_hint(content, "New hint")
|
||||
assert result == content, "Content should be unchanged when hint already exists"
|
||||
lines = result.splitlines()
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1
|
||||
|
||||
|
||||
class TestClaudeDisableModelInvocation:
|
||||
"""Verify disable-model-invocation is false for Claude skills."""
|
||||
|
||||
def test_setup_sets_disable_model_invocation_false(self, tmp_path):
|
||||
"""Generated SKILL.md files must have disable-model-invocation: false."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["disable-model-invocation"] is False, (
|
||||
f"{f.parent.name}: expected disable-model-invocation: false"
|
||||
)
|
||||
|
||||
def test_disable_model_invocation_not_true(self, tmp_path):
|
||||
"""No Claude skill should have disable-model-invocation: true."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
for f in created:
|
||||
if f.name != "SKILL.md":
|
||||
continue
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "disable-model-invocation: true" not in content, (
|
||||
f"{f.parent.name}: must not have disable-model-invocation: true"
|
||||
)
|
||||
|
||||
def test_non_claude_agents_lack_disable_model_invocation(self, tmp_path):
|
||||
"""Non-Claude skill agents should not get disable-model-invocation."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
fm = CommandRegistrar.build_skill_frontmatter(
|
||||
"codex", "speckit-plan", "desc", "templates/commands/plan.md"
|
||||
)
|
||||
assert "disable-model-invocation" not in fm
|
||||
assert "user-invocable" not in fm
|
||||
|
||||
def test_skills_default_post_process_preserves_content_without_hooks(self, tmp_path):
|
||||
"""SkillsIntegration agents without an override preserve non-hook content."""
|
||||
# ``agy`` is a plain SkillsIntegration with no post-process override,
|
||||
# so it stands in for the base-class default behavior.
|
||||
agy = get_integration("agy")
|
||||
if agy is None:
|
||||
return # agy not registered in this build
|
||||
content = "---\nname: test\n---\nBody"
|
||||
assert agy.post_process_skill_content(content) == content
|
||||
|
||||
|
||||
class TestClaudeForkContext:
|
||||
"""Verify context: fork is injected only for commands listed in FORK_CONTEXT_COMMANDS."""
|
||||
|
||||
def test_no_commands_fork_by_default(self):
|
||||
"""FORK_CONTEXT_COMMANDS is empty: no command opts into context: fork.
|
||||
|
||||
``analyze`` was removed (#3185) because its verbose report defeated the
|
||||
purpose of forking and compounded context overhead across repeated runs.
|
||||
"""
|
||||
assert FORK_CONTEXT_COMMANDS == {}
|
||||
|
||||
def test_analyze_skill_does_not_fork(self, tmp_path):
|
||||
"""speckit-analyze must run in the main session, not a forked subagent (#3185)."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
analyze_skill = tmp_path / ".claude/skills/speckit-analyze/SKILL.md"
|
||||
assert analyze_skill.exists()
|
||||
content = analyze_skill.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert "context" not in parsed
|
||||
assert "agent" not in parsed
|
||||
|
||||
def test_no_skills_fork(self, tmp_path):
|
||||
"""Skills not in FORK_CONTEXT_COMMANDS must not get context: fork."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-"):]
|
||||
if stem in FORK_CONTEXT_COMMANDS:
|
||||
continue
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert "context" not in parsed, (
|
||||
f"{f.parent.name}: must not have context frontmatter"
|
||||
)
|
||||
assert "agent" not in parsed, (
|
||||
f"{f.parent.name}: must not have agent frontmatter"
|
||||
)
|
||||
|
||||
def test_post_process_no_fork_for_skills(self):
|
||||
"""With FORK_CONTEXT_COMMANDS empty, post_process must not add context/agent."""
|
||||
i = get_integration("claude")
|
||||
for name in ("speckit-analyze", "speckit-plan"):
|
||||
content = f'---\nname: "{name}"\ndescription: "x"\n---\n\nBody\n'
|
||||
result = i.post_process_skill_content(content)
|
||||
parsed = yaml.safe_load(result.split("---", 2)[1])
|
||||
assert "context" not in parsed
|
||||
assert "agent" not in parsed
|
||||
|
||||
def test_fork_mechanism_injects_when_configured(self, monkeypatch):
|
||||
"""The injection mechanism still works for any command added to
|
||||
FORK_CONTEXT_COMMANDS, even though none ships enabled by default."""
|
||||
import specify_cli.integrations.claude as claude_mod
|
||||
|
||||
monkeypatch.setitem(
|
||||
claude_mod.FORK_CONTEXT_COMMANDS,
|
||||
"analyze",
|
||||
{"context": "fork", "agent": "general-purpose"},
|
||||
)
|
||||
i = get_integration("claude")
|
||||
content = '---\nname: "speckit-analyze"\ndescription: "x"\n---\n\nBody\n'
|
||||
result = i.post_process_skill_content(content)
|
||||
parts = result.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed.get("context") == "fork"
|
||||
assert parsed.get("agent") == "general-purpose"
|
||||
# Flags must land in the frontmatter, not the body.
|
||||
assert "context: fork" in parts[1]
|
||||
assert "context: fork" not in parts[2]
|
||||
# Re-running must not duplicate the injected keys.
|
||||
twice = i.post_process_skill_content(result)
|
||||
assert result == twice
|
||||
assert twice.count("context: fork") == 1
|
||||
assert twice.count("agent: general-purpose") == 1
|
||||
|
||||
|
||||
class TestClaudeHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections."""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("claude")
|
||||
m = IntegrationManifest("claude", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".claude/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
# specify.md has hook sections
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self, tmp_path):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = SkillsIntegration._inject_hook_command_note(content)
|
||||
twice = SkillsIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
|
||||
"""Already-noted hook sections should not suppress later sections."""
|
||||
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
f"{_HOOK_COMMAND_NOTE}"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
"\n"
|
||||
" - For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||
|
||||
def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
|
||||
"""Unrelated text should not trip the hook-note idempotence guard."""
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"This paragraph says replace dots in a different context.\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert "This paragraph says replace dots in a different context." in result
|
||||
assert result.count("replace dots (`.`) with hyphens") == 1
|
||||
|
||||
def test_hook_note_preserves_indentation(self, tmp_path):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [line for line in lines if "replace dots" in line][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_post_process_injects_all_claude_flags(self):
|
||||
"""post_process_skill_content should inject all Claude-specific fields."""
|
||||
i = get_integration("claude")
|
||||
content = (
|
||||
"---\nname: test\ndescription: test\n---\n\n"
|
||||
"- For each executable hook, output the following\n"
|
||||
)
|
||||
result = i.post_process_skill_content(content)
|
||||
assert "user-invocable: true" in result
|
||||
assert "disable-model-invocation: false" in result
|
||||
assert "replace dots" in result
|
||||
|
||||
|
||||
class TestSpeckitManifestRecordsSkippedFiles:
|
||||
"""Regression test for issue #2107.
|
||||
|
||||
``install_shared_infra`` must record every shared-infrastructure file
|
||||
under ``.specify/`` in ``speckit.manifest.json``, including files that
|
||||
were *skipped* because they already existed on disk and ``force=False``.
|
||||
|
||||
Before the fix, the skip branches in the scripts and templates loops
|
||||
appended to ``skipped_files`` without calling ``manifest.record_existing``.
|
||||
So when ``install_shared_infra`` ran with a fresh (or lost) manifest
|
||||
against an already-populated ``.specify/`` tree, every file went down the
|
||||
skip path, ``planned_copies`` and ``planned_templates`` stayed empty, and
|
||||
``manifest.save()`` wrote an empty ``files`` field — leaving the
|
||||
integration believing nothing was installed.
|
||||
|
||||
Reproduction (without the fix) using ``install_shared_infra`` directly:
|
||||
|
||||
install_shared_infra(p, "sh", ..., force=False) # 1st run → 10 files
|
||||
(p / ".specify/integrations/speckit.manifest.json").unlink()
|
||||
install_shared_infra(p, "sh", ..., force=False) # 2nd run → 0 files
|
||||
# ^^ BUG: empty
|
||||
"""
|
||||
|
||||
def _read_manifest_files(self, project_path: Path) -> dict:
|
||||
manifest_path = (
|
||||
project_path / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
)
|
||||
assert manifest_path.exists(), (
|
||||
f"speckit.manifest.json not written at {manifest_path}"
|
||||
)
|
||||
data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
# ``IntegrationManifest.save`` serialises a ``files`` dict — assert
|
||||
# the schema explicitly so a regression to a different key (e.g.
|
||||
# the internal ``_files`` attribute name) fails loudly instead of
|
||||
# being masked by a silent fallback.
|
||||
assert isinstance(data, dict), (
|
||||
f"manifest root is not a dict, got {type(data).__name__}"
|
||||
)
|
||||
assert "files" in data, (
|
||||
f"manifest missing 'files' key, got keys: {sorted(data.keys())}"
|
||||
)
|
||||
files = data["files"]
|
||||
assert isinstance(files, dict), (
|
||||
f"manifest 'files' is not a dict, got {type(files).__name__}"
|
||||
)
|
||||
return files
|
||||
|
||||
def test_install_shared_infra_records_skipped_files(self, tmp_path):
|
||||
"""With ``force=False`` and ``.specify/`` already populated, the
|
||||
manifest must still record every file — the skip branches are not
|
||||
allowed to drop files from the manifest."""
|
||||
from rich.console import Console
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
# Resolve the project's own packaged sources by walking up from this
|
||||
# test file to the repo root (which contains ``scripts/`` and
|
||||
# ``templates/`` that ``shared_scripts_source`` looks for).
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
console = Console(quiet=True)
|
||||
|
||||
# First run — fresh project, manifest gets populated normally.
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
first_files = self._read_manifest_files(tmp_path)
|
||||
assert first_files, "first install produced an empty manifest"
|
||||
|
||||
# Simulate a lost manifest while ``.specify/`` is still on disk
|
||||
# (e.g. the manifest was deleted, corrupted, or the layout was
|
||||
# extracted out-of-band).
|
||||
manifest_path = (
|
||||
tmp_path / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
)
|
||||
manifest_path.unlink()
|
||||
|
||||
# Second run — every file already exists, so every iteration takes
|
||||
# the skip branch. With the fix, those files are still recorded.
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
second_files = self._read_manifest_files(tmp_path)
|
||||
assert second_files, (
|
||||
"speckit.manifest.json files dict is empty after install with "
|
||||
"skipped files (issue #2107) — every file went down the skip "
|
||||
"branch but none were recorded"
|
||||
)
|
||||
|
||||
# The recovered manifest must cover everything the first run tracked.
|
||||
missing = set(first_files) - set(second_files)
|
||||
assert not missing, (
|
||||
f"these files were tracked on the first install but missing after "
|
||||
f"the skipped-files re-install: {sorted(missing)[:5]}"
|
||||
)
|
||||
|
||||
def test_install_shared_infra_handles_directory_at_script_destination(
|
||||
self, tmp_path
|
||||
):
|
||||
"""A non-file (directory) at a script's destination must NOT crash
|
||||
``install_shared_infra`` and must NOT be recorded in the manifest —
|
||||
the path still appears in the user-visible skipped-paths warning.
|
||||
"""
|
||||
from io import StringIO
|
||||
from rich.console import Console
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
output = StringIO()
|
||||
console = Console(file=output, force_terminal=False, width=200)
|
||||
|
||||
# Pre-create the .specify/scripts/bash tree, then plant a directory
|
||||
# where a script file is expected so the skip branch hits a
|
||||
# non-regular-file path.
|
||||
bash_dir = tmp_path / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True)
|
||||
(bash_dir / "common.sh").mkdir() # collision: dir where file expected
|
||||
|
||||
# Must not crash.
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
|
||||
files = self._read_manifest_files(tmp_path)
|
||||
assert ".specify/scripts/bash/common.sh" not in files, (
|
||||
"directory at script dst must not be recorded in the manifest"
|
||||
)
|
||||
text = output.getvalue()
|
||||
assert "common.sh" in text, (
|
||||
"directory-at-script-dst path must surface in the skipped warning"
|
||||
)
|
||||
|
||||
def test_install_shared_infra_handles_directory_at_template_destination(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Symmetric coverage for the templates loop: a directory at a
|
||||
template's destination must NOT crash install nor be recorded."""
|
||||
from io import StringIO
|
||||
from rich.console import Console
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
output = StringIO()
|
||||
console = Console(file=output, force_terminal=False, width=200)
|
||||
|
||||
templates_dir = tmp_path / ".specify" / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
|
||||
src_templates = repo_root / "templates"
|
||||
real_template = next(
|
||||
(
|
||||
p.name
|
||||
for p in src_templates.iterdir()
|
||||
if p.is_file()
|
||||
and not p.name.startswith(".")
|
||||
and p.name != "vscode-settings.json"
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert real_template, (
|
||||
"no real template found in repo to collide against"
|
||||
)
|
||||
(templates_dir / real_template).mkdir() # collision
|
||||
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"sh",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=repo_root,
|
||||
console=console,
|
||||
force=False,
|
||||
)
|
||||
|
||||
files = self._read_manifest_files(tmp_path)
|
||||
template_rel = f".specify/templates/{real_template}"
|
||||
assert template_rel not in files, (
|
||||
"directory at template dst must not be recorded in manifest"
|
||||
)
|
||||
text = output.getvalue()
|
||||
assert real_template in text, (
|
||||
"directory-at-template-dst path must surface in the skipped warning"
|
||||
)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Tests for ClineIntegration."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.cline import format_cline_command_name
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestClineCommandNameFormatter:
|
||||
"""Test the Cline command name formatter."""
|
||||
|
||||
def test_simple_name_without_prefix(self):
|
||||
"""Test formatting a simple name without 'speckit.' prefix."""
|
||||
assert format_cline_command_name("plan") == "speckit-plan"
|
||||
assert format_cline_command_name("tasks") == "speckit-tasks"
|
||||
assert format_cline_command_name("specify") == "speckit-specify"
|
||||
|
||||
def test_name_with_speckit_prefix(self):
|
||||
"""Test formatting a name that already has 'speckit.' prefix."""
|
||||
assert format_cline_command_name("speckit.plan") == "speckit-plan"
|
||||
assert format_cline_command_name("speckit.tasks") == "speckit-tasks"
|
||||
|
||||
def test_extension_command_name(self):
|
||||
"""Test formatting extension command names with dots."""
|
||||
assert (
|
||||
format_cline_command_name("speckit.my-extension.example")
|
||||
== "speckit-my-extension-example"
|
||||
)
|
||||
assert (
|
||||
format_cline_command_name("my-extension.example")
|
||||
== "speckit-my-extension-example"
|
||||
)
|
||||
|
||||
def test_idempotent_already_hyphenated(self):
|
||||
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
|
||||
assert format_cline_command_name("speckit-plan") == "speckit-plan"
|
||||
assert (
|
||||
format_cline_command_name("speckit-my-extension-example")
|
||||
== "speckit-my-extension-example"
|
||||
)
|
||||
|
||||
|
||||
class TestClineIntegration(MarkdownIntegrationTests):
|
||||
KEY = "cline"
|
||||
FOLDER = ".clinerules/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".clinerules/workflows"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd_name, expected_filename",
|
||||
[
|
||||
("plan", "speckit-plan.md"),
|
||||
("speckit.plan", "speckit-plan.md"),
|
||||
("speckit.git.commit", "speckit-git-commit.md"),
|
||||
("speckit", "speckit-speckit.md"),
|
||||
("speckitfoo", "speckit-speckitfoo.md"),
|
||||
],
|
||||
)
|
||||
def test_cline_command_filename(self, cmd_name, expected_filename):
|
||||
"""Verify Cline uses hyphenated filenames."""
|
||||
cline = get_integration("cline")
|
||||
assert cline.command_filename(cmd_name) == expected_filename
|
||||
|
||||
def test_cline_invoke_separator(self):
|
||||
"""Verify Cline uses hyphen as invoke separator."""
|
||||
cline = get_integration("cline")
|
||||
assert cline.invoke_separator == "-"
|
||||
assert cline.registrar_config["invoke_separator"] == "-"
|
||||
|
||||
def test_cline_name_injection_and_formatting(self):
|
||||
"""Verify Cline has inject_name and format_name configured."""
|
||||
cline = get_integration("cline")
|
||||
assert cline.registrar_config["inject_name"] is True
|
||||
assert cline.registrar_config["format_name"] == format_cline_command_name
|
||||
|
||||
def test_cline_handoff_rewrite(self):
|
||||
"""Verify Cline rewrites agent: speckit.foo to agent: speckit-foo."""
|
||||
cline = get_integration("cline")
|
||||
content = "---\nagent: speckit.plan\n---\n"
|
||||
rewritten = cline._rewrite_handoff_references(content)
|
||||
assert rewritten == "---\nagent: speckit-plan\n---\n"
|
||||
|
||||
def test_cline_hook_instruction_injection(self):
|
||||
"""Verify Cline injects the dot-to-hyphen note for hooks."""
|
||||
cline = get_integration("cline")
|
||||
content = "- For each executable hook, output the following:\n"
|
||||
injected = cline._inject_hook_command_note(content)
|
||||
assert "replace dots (`.`) with hyphens (`-`)" in injected
|
||||
assert "- For each executable hook, output the following:" in injected
|
||||
|
||||
def test_cline_hook_instruction_injection_no_trailing_newline(self):
|
||||
"""Note must not collapse onto the instruction line when the
|
||||
instruction is the final line with no trailing newline.
|
||||
|
||||
The injection regex matches the end-of-line via ``(\\r\\n|\\n|$)``, so
|
||||
the captured ``eol`` is empty on a file's last line that lacks a
|
||||
trailing newline. Without an ``or "\\n"`` fallback the note text and
|
||||
the instruction are emitted on the same line.
|
||||
"""
|
||||
cline = get_integration("cline")
|
||||
content = "- For each executable hook, output the following:" # no trailing \n
|
||||
injected = cline._inject_hook_command_note(content)
|
||||
assert "replace dots (`.`) with hyphens (`-`)" in injected
|
||||
# Instruction stays on its own line rather than being mashed onto the note.
|
||||
assert "\n- For each executable hook, output the following:" in injected
|
||||
|
||||
# -- Overrides for MarkdownIntegrationTests ---------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
cmd_files = [
|
||||
f
|
||||
for f in created
|
||||
if "scripts" not in f.parts
|
||||
and f.suffix == ".md"
|
||||
]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
assert f.name.startswith("speckit-")
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
specify_file = next(
|
||||
(f for f in cmd_files if f.name == "speckit-specify.md"), None
|
||||
)
|
||||
assert specify_file is not None
|
||||
specify_contents = specify_file.read_text(encoding="utf-8")
|
||||
assert "/speckit-plan" in specify_contents
|
||||
assert "/speckit.plan" not in specify_contents
|
||||
|
||||
def test_integration_flag_creates_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"int-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
self.KEY,
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.commands_dest(project)
|
||||
assert cmd_dir.is_dir()
|
||||
commands = sorted(cmd_dir.glob("speckit-*"))
|
||||
assert len(commands) > 0
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
"""Override to expect hyphenated speckit- prefix."""
|
||||
i = get_integration(self.KEY)
|
||||
cmd_dir = i.registrar_config["dir"]
|
||||
files = []
|
||||
|
||||
# Command files
|
||||
for stem in (
|
||||
self.COMMANDS_SUBDIR_STEMS
|
||||
if hasattr(self, "COMMANDS_SUBDIR_STEMS")
|
||||
else self.COMMAND_STEMS
|
||||
):
|
||||
files.append(f"{cmd_dir}/speckit-{stem.replace('.', '-')}.md")
|
||||
|
||||
# Framework files
|
||||
files.append(".specify/integration.json")
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in [
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in [
|
||||
"checklist-template.md",
|
||||
"constitution-template.md",
|
||||
"plan-template.md",
|
||||
"spec-template.md",
|
||||
"tasks-template.md",
|
||||
]:
|
||||
files.append(f".specify/templates/{name}")
|
||||
|
||||
files.append(".specify/memory/constitution.md")
|
||||
# Bundled workflow
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
return sorted(files)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Tests for CodebuddyIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestCodebuddyIntegration(MarkdownIntegrationTests):
|
||||
KEY = "codebuddy"
|
||||
FOLDER = ".codebuddy/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".codebuddy/commands"
|
||||
|
||||
def test_install_url_points_to_official_cli_install_docs(self):
|
||||
integration = get_integration(self.KEY)
|
||||
assert integration is not None
|
||||
|
||||
assert (
|
||||
integration.config["install_url"]
|
||||
== "https://www.codebuddy.cn/docs/cli/installation"
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Tests for CodexIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestCodexIntegration(SkillsIntegrationTests):
|
||||
KEY = "codex"
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
|
||||
|
||||
class TestCodexInitFlow:
|
||||
"""--integration codex creates expected files."""
|
||||
|
||||
def test_integration_codex_creates_skills(self, tmp_path):
|
||||
"""--integration codex should create skills in .agents/skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "codex", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan skill must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
target = tmp_path / "test-proj"
|
||||
target.mkdir()
|
||||
|
||||
integration = get_integration("codex")
|
||||
manifest = IntegrationManifest("codex", target)
|
||||
integration.setup(target, manifest, script_type="sh")
|
||||
|
||||
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_plan_skill_ignores_extension_config(self, tmp_path):
|
||||
"""The extension config must not influence rendered commands: the CLI
|
||||
no longer reads any context-file metadata when rendering."""
|
||||
import yaml
|
||||
|
||||
target = tmp_path / "test-proj"
|
||||
target.mkdir()
|
||||
ext_cfg = (
|
||||
target
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
ext_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||
ext_cfg.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"context_file": "FROM_CONFIG.md",
|
||||
"context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
integration = get_integration("codex")
|
||||
manifest = IntegrationManifest("codex", target)
|
||||
integration.setup(target, manifest, script_type="sh")
|
||||
|
||||
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "FROM_CONFIG.md" not in content
|
||||
assert "ALSO_CONFIG.md" not in content
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
|
||||
class TestCodexHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections.
|
||||
|
||||
Hook commands in ``extensions.yml`` use dotted ids like
|
||||
``speckit.git.commit`` but Codex skills are named with hyphens
|
||||
(``speckit-git-commit``). Without this note, Codex emits
|
||||
``/speckit.git.commit``, which does not resolve.
|
||||
"""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("codex")
|
||||
m = IntegrationManifest("codex", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".agents/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = CodexIntegration._inject_hook_command_note(content)
|
||||
twice = CodexIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_fills_missing_repeated_instructions(self):
|
||||
"""Already-noted hook sections should not suppress later sections."""
|
||||
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
f"{_HOOK_COMMAND_NOTE}"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
"\n"
|
||||
" - For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||
|
||||
def test_hook_note_not_suppressed_by_unrelated_phrase(self):
|
||||
"""Unrelated text should not trip the hook-note idempotence guard."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"This paragraph says replace dots in a different context.\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
assert "This paragraph says replace dots in a different context." in result
|
||||
assert result.count("replace dots (`.`) with hyphens") == 1
|
||||
|
||||
def test_hook_note_preserves_indentation(self):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [line for line in lines if "replace dots" in line][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_hook_note_when_instruction_is_final_line_without_newline(self):
|
||||
"""Note must not collapse onto the instruction line when the file
|
||||
ends without a trailing newline and the preceding line is not blank.
|
||||
"""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
# No blank line before the instruction and no trailing newline:
|
||||
# this is the case where the captured ``eol`` is empty and the
|
||||
# captured indent is also empty, so a missing line separator would
|
||||
# cause the note and instruction to collapse onto one line.
|
||||
content = (
|
||||
"---\nname: test\n---\n"
|
||||
"Body line\n"
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
result = CodexIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line_idx = next(
|
||||
i for i, line in enumerate(lines) if "replace dots" in line
|
||||
)
|
||||
instruction_line_idx = next(
|
||||
i for i, line in enumerate(lines)
|
||||
if line.lstrip().startswith("- For each executable hook")
|
||||
)
|
||||
assert note_line_idx < instruction_line_idx, (
|
||||
"Note must appear before the instruction"
|
||||
)
|
||||
assert "For each executable hook" not in lines[note_line_idx], (
|
||||
"Note and instruction must not be on the same line"
|
||||
)
|
||||
@@ -0,0 +1,827 @@
|
||||
"""Tests for CopilotIntegration."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TestCopilotIntegration:
|
||||
def test_copilot_key_and_config(self):
|
||||
copilot = get_integration("copilot")
|
||||
assert copilot is not None
|
||||
assert copilot.key == "copilot"
|
||||
assert copilot.config["folder"] == ".github/"
|
||||
assert copilot.config["commands_subdir"] == "agents"
|
||||
assert copilot.registrar_config["extension"] == ".agent.md"
|
||||
|
||||
def test_command_filename_agent_md(self):
|
||||
copilot = get_integration("copilot")
|
||||
assert copilot.command_filename("plan") == "speckit.plan.agent.md"
|
||||
|
||||
def test_setup_creates_agent_md_files(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
agent_files = [f for f in created if ".agent." in f.name]
|
||||
assert len(agent_files) > 0
|
||||
for f in agent_files:
|
||||
assert f.parent == tmp_path / ".github" / "agents"
|
||||
assert f.name.endswith(".agent.md")
|
||||
|
||||
def test_setup_warns_legacy_markdown_default_is_deprecated(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
with pytest.warns(UserWarning, match="Copilot legacy markdown mode is deprecated"):
|
||||
created = copilot.setup(tmp_path, m)
|
||||
|
||||
assert any(f.name.endswith(".agent.md") for f in created)
|
||||
|
||||
def test_skills_setup_does_not_warn_about_legacy_default(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
|
||||
assert not any(
|
||||
"Copilot legacy markdown mode is deprecated" in str(item.message)
|
||||
for item in caught
|
||||
)
|
||||
assert any(f.name == "SKILL.md" for f in created)
|
||||
|
||||
def test_setup_creates_companion_prompts(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m)
|
||||
prompt_files = [f for f in created if f.parent.name == "prompts"]
|
||||
assert len(prompt_files) > 0
|
||||
for f in prompt_files:
|
||||
assert f.name.endswith(".prompt.md")
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\nagent: speckit.")
|
||||
|
||||
def test_agent_and_prompt_counts_match(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m)
|
||||
agents = [f for f in created if ".agent.md" in f.name]
|
||||
prompts = [f for f in created if ".prompt.md" in f.name]
|
||||
assert len(agents) == len(prompts)
|
||||
|
||||
def test_setup_creates_vscode_settings_new(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
assert copilot._vscode_settings_path() is not None
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m)
|
||||
settings = tmp_path / ".vscode" / "settings.json"
|
||||
assert settings.exists()
|
||||
assert settings in created
|
||||
assert any("settings.json" in k for k in m.files)
|
||||
|
||||
def test_setup_merges_existing_vscode_settings(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
vscode_dir = tmp_path / ".vscode"
|
||||
vscode_dir.mkdir(parents=True)
|
||||
existing = {"editor.fontSize": 14, "custom.setting": True}
|
||||
(vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m)
|
||||
settings = tmp_path / ".vscode" / "settings.json"
|
||||
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||
assert data["editor.fontSize"] == 14
|
||||
assert data["custom.setting"] is True
|
||||
assert settings not in created
|
||||
assert not any("settings.json" in k for k in m.files)
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"Created file {rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = copilot.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m)
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = copilot.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
def test_directory_structure(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m)
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
assert agents_dir.is_dir()
|
||||
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
|
||||
assert len(agent_files) == 10
|
||||
expected_commands = {
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
}
|
||||
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m)
|
||||
agents_dir = tmp_path / ".github" / "agents"
|
||||
for agent_file in agents_dir.glob("speckit.*.agent.md"):
|
||||
content = agent_file.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "\nscripts:\n" not in content
|
||||
|
||||
def test_specify_agent_resolves_active_spec_template(self, tmp_path):
|
||||
"""Generated specify agent must not hardcode the core spec template."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
||||
content = specify_file.read_text(encoding="utf-8")
|
||||
|
||||
assert "specify preset resolve spec-template" in content
|
||||
assert "resolved active `spec-template`" in content
|
||||
assert "Copy `.specify/templates/spec-template.md`" not in content
|
||||
assert "Load `.specify/templates/spec-template.md`" not in content
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan command must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
copilot.setup(tmp_path, m)
|
||||
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration copilot --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "inventory-sh"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
".github/agents/speckit.constitution.agent.md",
|
||||
".github/agents/speckit.converge.agent.md",
|
||||
".github/agents/speckit.implement.agent.md",
|
||||
".github/agents/speckit.plan.agent.md",
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
".github/prompts/speckit.constitution.prompt.md",
|
||||
".github/prompts/speckit.converge.prompt.md",
|
||||
".github/prompts/speckit.implement.prompt.md",
|
||||
".github/prompts/speckit.plan.prompt.md",
|
||||
".github/prompts/speckit.specify.prompt.md",
|
||||
".github/prompts/speckit.tasks.prompt.md",
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".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/setup-tasks.sh",
|
||||
".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",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
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 copilot --script ps."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "inventory-ps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "ps",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
".github/agents/speckit.constitution.agent.md",
|
||||
".github/agents/speckit.converge.agent.md",
|
||||
".github/agents/speckit.implement.agent.md",
|
||||
".github/agents/speckit.plan.agent.md",
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
".github/prompts/speckit.constitution.prompt.md",
|
||||
".github/prompts/speckit.converge.prompt.md",
|
||||
".github/prompts/speckit.implement.prompt.md",
|
||||
".github/prompts/speckit.plan.prompt.md",
|
||||
".github/prompts/speckit.specify.prompt.md",
|
||||
".github/prompts/speckit.tasks.prompt.md",
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".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/setup-tasks.ps1",
|
||||
".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",
|
||||
".specify/memory/constitution.md",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
def test_default_cli_init_warns_legacy_markdown_is_deprecated(self, tmp_path):
|
||||
"""Default Copilot init should warn users about the future skills default."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "default-warning"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
with pytest.warns(
|
||||
UserWarning,
|
||||
match="Copilot legacy markdown mode is deprecated",
|
||||
):
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
def test_skills_cli_init_does_not_warn_about_legacy_markdown(self, tmp_path):
|
||||
"""Explicit Copilot skills mode should not warn about the legacy default."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "skills-no-warning"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not any(
|
||||
"Copilot legacy markdown mode is deprecated" in str(item.message)
|
||||
for item in caught
|
||||
)
|
||||
|
||||
|
||||
class TestCopilotSkillsMode:
|
||||
"""Tests for Copilot integration in --skills mode."""
|
||||
|
||||
_SKILL_COMMANDS = [
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
|
||||
def _make_copilot(self):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
return CopilotIntegration()
|
||||
|
||||
def _setup_skills(self, copilot, tmp_path):
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.setup(tmp_path, m, parsed_options={"skills": True})
|
||||
return created, m
|
||||
|
||||
# -- Options ----------------------------------------------------------
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
copilot = get_integration("copilot")
|
||||
opts = copilot.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 1
|
||||
assert skills_opts[0].is_flag is True
|
||||
assert skills_opts[0].default is False
|
||||
|
||||
# -- Skills directory structure ---------------------------------------
|
||||
|
||||
def test_skills_creates_skill_files(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
assert len(created) > 0
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
assert f.exists()
|
||||
assert f.parent.name.startswith("speckit-")
|
||||
|
||||
def test_skills_directory_under_github_skills(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skills_dir = tmp_path / ".github" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
assert f.resolve().parent.parent == skills_dir.resolve(), (
|
||||
f"{f} is not under {skills_dir}"
|
||||
)
|
||||
|
||||
def test_skills_directory_structure(self, tmp_path):
|
||||
"""Each command produces speckit-<name>/SKILL.md."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
expected_commands = set(self._SKILL_COMMANDS)
|
||||
actual_commands = set()
|
||||
for f in skill_files:
|
||||
skill_dir_name = f.parent.name
|
||||
assert skill_dir_name.startswith("speckit-")
|
||||
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
# -- No companion files in skills mode --------------------------------
|
||||
|
||||
def test_skills_no_prompt_md_companions(self, tmp_path):
|
||||
"""Skills mode must not generate .prompt.md companion files."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
prompt_files = [f for f in created if f.name.endswith(".prompt.md")]
|
||||
assert prompt_files == []
|
||||
prompts_dir = tmp_path / ".github" / "prompts"
|
||||
if prompts_dir.exists():
|
||||
assert list(prompts_dir.iterdir()) == []
|
||||
|
||||
def test_skills_no_vscode_settings(self, tmp_path):
|
||||
"""Skills mode must not create or merge .vscode/settings.json."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
settings = tmp_path / ".vscode" / "settings.json"
|
||||
assert not settings.exists()
|
||||
|
||||
def test_skills_no_agent_md_files(self, tmp_path):
|
||||
"""Skills mode must not produce .agent.md files."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
agent_files = [f for f in created if f.name.endswith(".agent.md")]
|
||||
assert agent_files == []
|
||||
|
||||
# -- Frontmatter structure --------------------------------------------
|
||||
|
||||
def test_skill_frontmatter_structure(self, tmp_path):
|
||||
"""SKILL.md must have name, description, compatibility, metadata."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "name" in fm, f"{f} frontmatter missing 'name'"
|
||||
assert "description" in fm, f"{f} frontmatter missing 'description'"
|
||||
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
|
||||
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
|
||||
assert fm["metadata"]["author"] == "github-spec-kit"
|
||||
|
||||
# -- Copilot-specific post-processing ---------------------------------
|
||||
|
||||
def test_post_process_skill_content_does_not_inject_mode(self):
|
||||
"""post_process_skill_content() must NOT inject mode: — VS Code Copilot does not support it."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-plan"\n'
|
||||
'description: "Plan workflow"\n'
|
||||
"---\n"
|
||||
"\nBody content\n"
|
||||
)
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "mode:" not in updated
|
||||
|
||||
def test_post_process_skill_content_injects_hook_note(self):
|
||||
"""post_process_skill_content() should inject shared hook guidance but not mode:."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-specify"\n'
|
||||
'description: "Specify workflow"\n'
|
||||
"---\n"
|
||||
"\n- For each executable hook, output the following\n"
|
||||
)
|
||||
updated = copilot.post_process_skill_content(content)
|
||||
assert "replace dots" in updated
|
||||
assert "mode:" not in updated
|
||||
|
||||
def test_post_process_idempotent(self):
|
||||
"""post_process_skill_content() must be idempotent."""
|
||||
copilot = self._make_copilot()
|
||||
content = (
|
||||
"---\n"
|
||||
'name: "speckit-plan"\n'
|
||||
'description: "Plan workflow"\n'
|
||||
"---\n"
|
||||
"\nBody content\n"
|
||||
)
|
||||
first = copilot.post_process_skill_content(content)
|
||||
second = copilot.post_process_skill_content(first)
|
||||
assert first == second
|
||||
|
||||
def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path):
|
||||
"""Generated SKILL.md files must NOT contain mode: — VS Code Copilot does not support it."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
fm = yaml.safe_load(parts[1])
|
||||
assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field"
|
||||
|
||||
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
||||
"""Generated skills with hook sections should include shared hook guidance."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content
|
||||
|
||||
# -- Template processing ----------------------------------------------
|
||||
|
||||
def test_skills_templates_are_processed(self, tmp_path):
|
||||
"""Skill body must have placeholders replaced."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_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}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
def test_skills_command_refs_use_hyphen(self, tmp_path):
|
||||
"""Copilot skills mode must use /speckit-<name> not /speckit.<name>."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "/speckit." not in content, (
|
||||
f"{f.name} contains dot-notation /speckit. reference; "
|
||||
f"skills mode must use /speckit-<name>"
|
||||
)
|
||||
|
||||
def test_skills_mode_invoke_separator(self):
|
||||
"""Copilot effective_invoke_separator should reflect skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.effective_invoke_separator() == "."
|
||||
assert copilot.effective_invoke_separator({"skills": True}) == "-"
|
||||
assert copilot.effective_invoke_separator({"skills": False}) == "."
|
||||
|
||||
def test_skill_body_has_content(self, tmp_path):
|
||||
"""Each SKILL.md body should contain template content."""
|
||||
copilot = self._make_copilot()
|
||||
created, _ = self._setup_skills(copilot, tmp_path)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
body = parts[2].strip() if len(parts) >= 3 else ""
|
||||
assert len(body) > 0, f"{f} has empty body"
|
||||
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan skill must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
# -- Manifest tracking ------------------------------------------------
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
created, m = self._setup_skills(copilot, tmp_path)
|
||||
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"
|
||||
|
||||
# -- Install/uninstall roundtrip --------------------------------------
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = copilot.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
created = copilot.install(tmp_path, m, parsed_options={"skills": True})
|
||||
m.save()
|
||||
modified_file = created[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = copilot.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- build_command_invocation -----------------------------------------
|
||||
|
||||
def test_build_command_invocation_skills_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
copilot._skills_mode = True
|
||||
assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan"
|
||||
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
||||
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
|
||||
|
||||
def test_build_command_invocation_skills_extension_command(self):
|
||||
copilot = self._make_copilot()
|
||||
copilot._skills_mode = True
|
||||
assert copilot.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||
assert copilot.build_command_invocation("git.commit") == "/speckit-git-commit"
|
||||
|
||||
def test_build_command_invocation_default_mode(self):
|
||||
copilot = self._make_copilot()
|
||||
assert copilot.build_command_invocation("plan", "my args") == "my args"
|
||||
assert copilot.build_command_invocation("plan") == ""
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
|
||||
def test_skills_setup_does_not_write_context_section(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
# -- CLI integration test ---------------------------------------------
|
||||
|
||||
def test_init_with_integration_options_skills(self, tmp_path):
|
||||
"""specify init --integration copilot --integration-options='--skills' scaffolds skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "copilot-skills"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
skills_dir = project / ".github" / "skills"
|
||||
assert skills_dir.is_dir(), "Skills directory was not created"
|
||||
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
|
||||
assert plan_skill.exists(), "speckit-plan/SKILL.md not found"
|
||||
# Verify no default-mode artifacts
|
||||
assert not (project / ".github" / "agents").exists()
|
||||
assert not (project / ".github" / "prompts").exists()
|
||||
assert not (project / ".vscode" / "settings.json").exists()
|
||||
|
||||
def test_complete_file_inventory_skills_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration copilot --integration-options='--skills' --script sh."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "inventory-skills-sh"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh",
|
||||
], 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() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
# Skill files (core commands)
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
# Integration metadata
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
# Scripts (sh)
|
||||
".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/setup-tasks.sh",
|
||||
# Templates
|
||||
".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",
|
||||
".specify/memory/constitution.md",
|
||||
# Bundled workflow
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
|
||||
# -- Singleton leak: _skills_mode must reset --------------------------
|
||||
|
||||
def test_skills_mode_resets_on_default_setup(self, tmp_path):
|
||||
"""setup() with skills=True then without must reset _skills_mode."""
|
||||
copilot = self._make_copilot()
|
||||
|
||||
# First call: skills mode
|
||||
(tmp_path / "proj1").mkdir()
|
||||
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
|
||||
copilot.setup(tmp_path / "proj1", m1, parsed_options={"skills": True})
|
||||
assert copilot._skills_mode is True
|
||||
|
||||
# Second call: default mode (no skills option)
|
||||
(tmp_path / "proj2").mkdir()
|
||||
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
|
||||
copilot.setup(tmp_path / "proj2", m2)
|
||||
assert copilot._skills_mode is False
|
||||
|
||||
# build_command_invocation must use default (dotted) mode
|
||||
assert copilot.build_command_invocation("plan", "args") == "args"
|
||||
|
||||
# -- Auto-detection must ignore unrelated .github/skills/ -------------
|
||||
|
||||
def test_dispatch_ignores_unrelated_skills_directory(self, tmp_path):
|
||||
"""dispatch_command() must not treat unrelated .github/skills/ as skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
# Create a .github/skills/ with non-speckit content (e.g. GitHub Skills training)
|
||||
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
|
||||
unrelated.mkdir(parents=True)
|
||||
(unrelated / "README.md").write_text("# GitHub Skills training\n")
|
||||
|
||||
# Should NOT detect skills mode — cli_args should contain --agent
|
||||
import unittest.mock as mock
|
||||
with mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--agent" in call_args, (
|
||||
f"Expected --agent in cli_args but got: {call_args}"
|
||||
)
|
||||
assert "speckit.plan" in call_args
|
||||
|
||||
def test_dispatch_detects_speckit_skills_layout(self, tmp_path):
|
||||
"""dispatch_command() detects speckit-*/SKILL.md as skills mode."""
|
||||
copilot = self._make_copilot()
|
||||
skill_dir = tmp_path / ".github" / "skills" / "speckit-plan"
|
||||
skill_dir.mkdir(parents=True)
|
||||
(skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n")
|
||||
|
||||
import unittest.mock as mock
|
||||
with mock.patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
||||
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
|
||||
call_args = mock_run.call_args[0][0]
|
||||
assert "--agent" not in call_args, (
|
||||
f"Skills mode should not use --agent, got: {call_args}"
|
||||
)
|
||||
prompt = call_args[call_args.index("-p") + 1]
|
||||
assert "/speckit-plan" in prompt, (
|
||||
f"Skills mode prompt should invoke /speckit-plan, got: {prompt}"
|
||||
)
|
||||
assert "my args" in prompt, (
|
||||
f"Skills mode prompt should preserve user args, got: {prompt}"
|
||||
)
|
||||
|
||||
# -- Next-steps display for Copilot skills mode -----------------------
|
||||
|
||||
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
|
||||
"""specify init --integration copilot --integration-options='--skills' shows /speckit-plan not /speckit.plan."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
project = tmp_path / "copilot-nextsteps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
# Skills mode should show /speckit-plan (hyphenated)
|
||||
assert "/speckit-plan" in result.output, (
|
||||
f"Expected /speckit-plan in next steps but got:\n{result.output}"
|
||||
)
|
||||
# Must NOT show the dotted /speckit.plan form
|
||||
assert "/speckit.plan" not in result.output, (
|
||||
f"Should not show /speckit.plan in skills mode:\n{result.output}"
|
||||
)
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Tests for CursorAgentIntegration."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestCursorAgentIntegration(SkillsIntegrationTests):
|
||||
KEY = "cursor-agent"
|
||||
FOLDER = ".cursor/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".cursor/skills"
|
||||
|
||||
|
||||
class TestCursorAgentInitFlow:
|
||||
"""--integration cursor-agent creates expected files."""
|
||||
|
||||
def test_integration_cursor_agent_creates_skills(self, tmp_path):
|
||||
"""--integration cursor-agent should create skills in .cursor/skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, ["init", str(target), "--integration", "cursor-agent", "--ignore-agent-tools", "--script", "sh"])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration cursor-agent failed: {result.output}"
|
||||
assert (target / ".cursor" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestCursorAgentCliDispatch:
|
||||
"""Verify the CLI dispatch path for cursor-agent (issue #2629).
|
||||
|
||||
The ``cursor-agent`` CLI supports headless execution via ``-p`` (with
|
||||
full tool access including write/shell) and requires ``--trust`` to
|
||||
bypass the Workspace Trust prompt. These tests pin the exact argv
|
||||
shape that the workflow runner will use.
|
||||
"""
|
||||
|
||||
def test_requires_cli_is_false_for_ide_first_flow(self):
|
||||
"""``requires_cli`` must stay False so the IDE-only flow keeps working.
|
||||
|
||||
``specify init --integration cursor-agent`` (without ``--ignore-agent-tools``)
|
||||
treats ``requires_cli=True`` as a hard precheck and fails when the
|
||||
``cursor-agent`` CLI isn't on PATH — even though the Cursor IDE
|
||||
/ skills flow can run without it. Workflow dispatch support is
|
||||
signalled by overriding ``build_exec_args()`` instead, mirroring
|
||||
``CopilotIntegration``.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
assert i.config.get("requires_cli") is False
|
||||
|
||||
def test_install_url_is_set(self):
|
||||
i = get_integration("cursor-agent")
|
||||
url = i.config.get("install_url")
|
||||
assert url is not None
|
||||
# CodeQL: use a hostname comparison instead of a substring check
|
||||
# to avoid the "Incomplete URL substring sanitization" warning
|
||||
# (substring "cursor.com" can also appear in attacker-controlled
|
||||
# positions of an arbitrary URL).
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
assert host == "cursor.com" or host.endswith(".cursor.com")
|
||||
|
||||
def test_build_exec_args_default_includes_headless_flags_and_json(self):
|
||||
"""Default argv emits the full headless flag set: -p --trust
|
||||
--approve-mcps --force, then prompt, then --output-format json.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-specify some-feature")
|
||||
assert args == [
|
||||
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
|
||||
"/speckit-specify some-feature",
|
||||
"--output-format", "json",
|
||||
]
|
||||
|
||||
def test_build_exec_args_text_output_omits_format(self):
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert args == [
|
||||
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
|
||||
"/speckit-plan",
|
||||
]
|
||||
|
||||
def test_build_exec_args_with_model(self):
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args(
|
||||
"/speckit-specify", model="sonnet-4-thinking", output_json=False
|
||||
)
|
||||
assert args == [
|
||||
"cursor-agent", "-p", "--trust", "--approve-mcps", "--force",
|
||||
"/speckit-specify",
|
||||
"--model", "sonnet-4-thinking",
|
||||
]
|
||||
|
||||
def test_build_exec_args_contains_mandatory_headless_flags(self):
|
||||
"""The four headless flags must always appear together.
|
||||
|
||||
``--approve-mcps`` is required so MCP servers (e.g. dingtalk-doc)
|
||||
actually load in headless mode; ``--force`` is required so the
|
||||
agent doesn't block on tool-call approval prompts during the
|
||||
speckit workflow. Together with ``-p`` and ``--trust`` they
|
||||
bring cursor-agent's headless behaviour in line with
|
||||
``claude -p`` / ``codex --exec`` from spec-kit's perspective.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-implement", output_json=False)
|
||||
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
|
||||
assert flag in args, f"missing mandatory headless flag: {flag}"
|
||||
|
||||
def test_build_exec_args_supports_dispatch_without_requires_cli(self):
|
||||
"""``build_exec_args`` must return argv even though ``requires_cli``
|
||||
is ``False``.
|
||||
|
||||
``CursorAgentIntegration`` opts out of the ``requires_cli`` hard
|
||||
precheck (so ``specify init`` doesn't fail when the CLI isn't on
|
||||
PATH) but still supports workflow dispatch. The presence of a
|
||||
non-``None`` argv from ``build_exec_args()`` is what the engine
|
||||
keys off — pin that invariant.
|
||||
"""
|
||||
i = get_integration("cursor-agent")
|
||||
assert i.config.get("requires_cli") is False
|
||||
argv = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert argv is not None
|
||||
assert argv[0] == "cursor-agent"
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE`` overrides argv[0].
|
||||
|
||||
Every other CLI-dispatch integration (codex, devin, ...) routes
|
||||
argv[0] through ``_resolve_executable()`` so operators can pin a
|
||||
binary path (issue #2596). cursor-agent hardcoded ``self.key`` and
|
||||
silently ignored the documented override.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXECUTABLE", "/custom/cursor"
|
||||
)
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert args[0] == "/custom/cursor"
|
||||
# The mandatory headless flags must still be present.
|
||||
for flag in ("-p", "--trust", "--approve-mcps", "--force"):
|
||||
assert flag in args
|
||||
|
||||
def test_build_exec_args_honors_extra_args_override(self, monkeypatch):
|
||||
"""``SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS`` flags are injected
|
||||
*before* Spec Kit's canonical ``--model`` / ``--output-format`` flags.
|
||||
|
||||
The ``_apply_extra_args_env_var()`` hook (issue #2595) was never
|
||||
invoked by cursor-agent, so operator-supplied flags were dropped.
|
||||
Insertion order is the real contract: extra args must land after the
|
||||
mandatory headless flags but before ``--model`` / ``--output-format``,
|
||||
so they cannot clobber, displace, or reorder Spec Kit's canonical
|
||||
trailing flags. Exercise with both a model and JSON output so both
|
||||
canonical flags are present to pin against.
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CURSOR_AGENT_EXTRA_ARGS", "--foo bar"
|
||||
)
|
||||
i = get_integration("cursor-agent")
|
||||
args = i.build_exec_args(
|
||||
"/speckit-plan", model="sonnet-4-thinking", output_json=True
|
||||
)
|
||||
assert "--foo" in args
|
||||
assert "bar" in args
|
||||
# "bar" is the value of "--foo": the tokens stay adjacent and in order.
|
||||
assert args.index("bar") == args.index("--foo") + 1
|
||||
# Extra args are inserted before the canonical flags, so they cannot
|
||||
# clobber or reorder them (the behavioral contract this test guards).
|
||||
assert args.index("--foo") < args.index("--model")
|
||||
assert args.index("--foo") < args.index("--output-format")
|
||||
# The canonical flags themselves remain intact and correctly paired.
|
||||
assert args[args.index("--model") + 1] == "sonnet-4-thinking"
|
||||
assert args[args.index("--output-format") + 1] == "json"
|
||||
|
||||
def test_build_command_invocation_uses_hyphenated_skill_name(self):
|
||||
"""SkillsIntegration: /speckit-plan (not /speckit.plan)."""
|
||||
i = get_integration("cursor-agent")
|
||||
assert i.build_command_invocation("speckit.plan", "feature-x") == "/speckit-plan feature-x"
|
||||
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||
|
||||
def test_dispatch_command_resolves_cmd_shim_for_subprocess(self):
|
||||
"""``.cmd`` shims must be resolved to their full path before ``subprocess.run``.
|
||||
|
||||
``cursor-agent`` (and other npm-installed CLIs on Windows) ship as
|
||||
``cursor-agent.cmd`` wrappers. ``shutil.which`` honors ``PATHEXT``
|
||||
and finds them, but Python's ``subprocess.run`` calls
|
||||
``CreateProcess`` which does **not** consult ``PATHEXT`` and fails
|
||||
with ``WinError 2`` on a bare ``["cursor-agent", ...]`` argv. The
|
||||
fix in ``base.py::dispatch_command`` resolves ``exec_args[0]`` via
|
||||
``shutil.which`` so the full ``.cmd`` path is what reaches
|
||||
``CreateProcess``.
|
||||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
i = get_integration("cursor-agent")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = "ok"
|
||||
mock_result.stderr = ""
|
||||
|
||||
fake_path = r"C:\Users\foo\AppData\Local\cursor-agent\cursor-agent.CMD"
|
||||
with patch(
|
||||
"specify_cli.integrations.base.shutil.which", return_value=fake_path
|
||||
), patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
result = i.dispatch_command(
|
||||
"speckit.plan", args="feature-x", stream=False, timeout=5
|
||||
)
|
||||
|
||||
assert result["exit_code"] == 0
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == fake_path, f"expected resolved .CMD path, got: {argv[0]!r}"
|
||||
assert argv[1:6] == ["-p", "--trust", "--approve-mcps", "--force", "/speckit-plan feature-x"]
|
||||
|
||||
def test_dispatch_command_passthrough_when_shutil_which_finds_nothing(self):
|
||||
"""If ``shutil.which`` returns ``None``, leave argv unchanged so the
|
||||
existing ``FileNotFoundError`` path remains observable to callers."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
i = get_integration("cursor-agent")
|
||||
|
||||
mock_result = MagicMock()
|
||||
mock_result.returncode = 0
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch(
|
||||
"specify_cli.integrations.base.shutil.which", return_value=None
|
||||
), patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
i.dispatch_command("speckit.plan", stream=False, timeout=5)
|
||||
|
||||
argv = mock_run.call_args[0][0]
|
||||
assert argv[0] == "cursor-agent"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for DevinIntegration."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestDevinIntegration(SkillsIntegrationTests):
|
||||
KEY = "devin"
|
||||
FOLDER = ".devin/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".devin/skills"
|
||||
|
||||
|
||||
class TestDevinBuildExecArgs:
|
||||
"""Regression tests for DevinIntegration.build_exec_args.
|
||||
|
||||
Devin's CLI has no --output-format flag, so build_exec_args must
|
||||
omit it regardless of the output_json argument. The integration
|
||||
must also remain dispatchable (must not return None, which is the
|
||||
codebase's IDE-only sentinel checked by CommandStep).
|
||||
"""
|
||||
|
||||
def test_returns_args_not_none_for_dispatch(self):
|
||||
"""Devin is CLI-dispatchable; build_exec_args must not return None."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
impl = DevinIntegration()
|
||||
args = impl.build_exec_args("test prompt")
|
||||
assert args is not None, (
|
||||
"DevinIntegration.build_exec_args must not return None. "
|
||||
"None is the codebase sentinel for IDE-only integrations "
|
||||
"(see KilocodeIntegration); Devin is dispatchable via 'devin -p'."
|
||||
)
|
||||
assert args[:3] == ["devin", "-p", "test prompt"]
|
||||
|
||||
def test_output_json_does_not_emit_output_format_flag(self):
|
||||
"""Devin has no --output-format flag; output_json=True must not add it."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
impl = DevinIntegration()
|
||||
args_json = impl.build_exec_args("hello", output_json=True)
|
||||
args_text = impl.build_exec_args("hello", output_json=False)
|
||||
|
||||
assert "--output-format" not in args_json
|
||||
assert "json" not in args_json[3:]
|
||||
# The two should be identical: output_json is documented as having
|
||||
# no effect on the command line for Devin (plain-text stdout).
|
||||
assert args_json == args_text
|
||||
|
||||
def test_model_flag_passed_through(self):
|
||||
"""--model is supported and should appear when provided."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
impl = DevinIntegration()
|
||||
args = impl.build_exec_args("hi", model="claude-sonnet-4")
|
||||
assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"]
|
||||
|
||||
|
||||
class TestDevinInitFlow:
|
||||
"""--integration devin creates expected files."""
|
||||
|
||||
def test_integration_devin_creates_skills(self, tmp_path):
|
||||
"""--integration devin should create skills directory."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["init", str(target), "--integration", "devin", "--ignore-agent-tools", "--script", "sh"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --integration devin failed: {result.output}"
|
||||
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Tests for FirebenderIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestFirebenderIntegration(MarkdownIntegrationTests):
|
||||
KEY = "firebender"
|
||||
FOLDER = ".firebender/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".firebender/commands"
|
||||
|
||||
# Firebender reads custom slash commands from ``.firebender/commands/*.mdc``,
|
||||
# so this integration uses the ``.mdc`` extension instead of the ``.md``
|
||||
# default the base mixin assumes. Override the two extension-specific tests.
|
||||
def test_registrar_config(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "markdown"
|
||||
assert i.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert i.registrar_config["extension"] == ".mdc"
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
cmd_files = [f for f in created if "scripts" not in f.parts]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
assert f.name.startswith("speckit.")
|
||||
assert f.name.endswith(".mdc")
|
||||
|
||||
def _expected_files(self, script_variant: str) -> list[str]:
|
||||
# Firebender emits ``.mdc`` command files, so remap the base mixin's
|
||||
# ``.md`` expectations for files under this integration's command dir.
|
||||
cmd_dir = get_integration(self.KEY).registrar_config["dir"]
|
||||
prefix = cmd_dir + "/"
|
||||
return sorted(
|
||||
f[:-3] + ".mdc" if f.startswith(prefix) and f.endswith(".md") else f
|
||||
for f in super()._expected_files(script_variant)
|
||||
)
|
||||
@@ -0,0 +1,477 @@
|
||||
"""Tests for ForgeIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
from specify_cli.integrations.forge import format_forge_command_name
|
||||
|
||||
|
||||
class TestForgeCommandNameFormatter:
|
||||
"""Test the centralized Forge command name formatter."""
|
||||
|
||||
def test_simple_name_without_prefix(self):
|
||||
"""Test formatting a simple name without 'speckit.' prefix."""
|
||||
assert format_forge_command_name("plan") == "speckit-plan"
|
||||
assert format_forge_command_name("tasks") == "speckit-tasks"
|
||||
assert format_forge_command_name("specify") == "speckit-specify"
|
||||
|
||||
def test_name_with_speckit_prefix(self):
|
||||
"""Test formatting a name that already has 'speckit.' prefix."""
|
||||
assert format_forge_command_name("speckit.plan") == "speckit-plan"
|
||||
assert format_forge_command_name("speckit.tasks") == "speckit-tasks"
|
||||
|
||||
def test_extension_command_name(self):
|
||||
"""Test formatting extension command names with dots."""
|
||||
assert format_forge_command_name("speckit.my-extension.example") == "speckit-my-extension-example"
|
||||
assert format_forge_command_name("my-extension.example") == "speckit-my-extension-example"
|
||||
|
||||
def test_complex_nested_name(self):
|
||||
"""Test formatting deeply nested command names."""
|
||||
assert format_forge_command_name("speckit.jira.sync-status") == "speckit-jira-sync-status"
|
||||
assert format_forge_command_name("speckit.foo.bar.baz") == "speckit-foo-bar-baz"
|
||||
|
||||
def test_name_with_hyphens_preserved(self):
|
||||
"""Test that existing hyphens are preserved."""
|
||||
assert format_forge_command_name("my-extension") == "speckit-my-extension"
|
||||
assert format_forge_command_name("speckit.my-ext.test-cmd") == "speckit-my-ext-test-cmd"
|
||||
|
||||
def test_alias_formatting(self):
|
||||
"""Test formatting alias names."""
|
||||
assert format_forge_command_name("speckit.my-extension.example-short") == "speckit-my-extension-example-short"
|
||||
|
||||
def test_idempotent_already_hyphenated(self):
|
||||
"""Test that already-hyphenated names are returned unchanged (idempotent)."""
|
||||
assert format_forge_command_name("speckit-plan") == "speckit-plan"
|
||||
assert format_forge_command_name("speckit-my-extension-example") == "speckit-my-extension-example"
|
||||
assert format_forge_command_name("speckit-jira-sync-status") == "speckit-jira-sync-status"
|
||||
|
||||
|
||||
class TestForgeIntegration:
|
||||
def test_forge_key_and_config(self):
|
||||
forge = get_integration("forge")
|
||||
assert forge is not None
|
||||
assert forge.key == "forge"
|
||||
assert forge.config["folder"] == ".forge/"
|
||||
assert forge.config["commands_subdir"] == "commands"
|
||||
assert forge.config["requires_cli"] is True
|
||||
assert forge.registrar_config["args"] == "{{parameters}}"
|
||||
assert forge.registrar_config["extension"] == ".md"
|
||||
|
||||
def test_command_filename_md(self):
|
||||
forge = get_integration("forge")
|
||||
assert forge.command_filename("plan") == "speckit.plan.md"
|
||||
|
||||
def test_setup_creates_md_files(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
# Separate command files from scripts
|
||||
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
|
||||
assert len(command_files) > 0
|
||||
for f in command_files:
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.setup(tmp_path, m)
|
||||
for f in created:
|
||||
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
||||
assert rel in m.files, f"Created file {rel} not tracked in manifest"
|
||||
|
||||
def test_install_uninstall_roundtrip(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
for f in created:
|
||||
assert f.exists()
|
||||
removed, skipped = forge.uninstall(tmp_path, m)
|
||||
assert len(removed) == len(created)
|
||||
assert skipped == []
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
created = forge.install(tmp_path, m)
|
||||
m.save()
|
||||
# Modify a command file (not a script)
|
||||
command_files = [f for f in created if f.parent == tmp_path / ".forge" / "commands"]
|
||||
modified_file = command_files[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = forge.uninstall(tmp_path, m)
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
def test_directory_structure(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
assert commands_dir.is_dir()
|
||||
|
||||
# Derive expected command names from the Forge command templates so the test
|
||||
# stays in sync if templates are added/removed.
|
||||
templates = forge.list_command_templates()
|
||||
expected_commands = {t.stem for t in templates}
|
||||
assert len(expected_commands) > 0, "No command templates found"
|
||||
|
||||
# Check generated files match templates
|
||||
command_files = sorted(commands_dir.glob("speckit.*.md"))
|
||||
assert len(command_files) == len(expected_commands)
|
||||
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".md") for f in command_files}
|
||||
assert actual_commands == expected_commands
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
import re
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
# Check standard replacements
|
||||
assert "{SCRIPT}" not in content, f"{cmd_file.name} has unprocessed {{SCRIPT}}"
|
||||
assert "__AGENT__" not in content, f"{cmd_file.name} has unprocessed __AGENT__"
|
||||
assert "{ARGS}" not in content, f"{cmd_file.name} has unprocessed {{ARGS}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{cmd_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
# Check Forge-specific: $ARGUMENTS should be replaced with {{parameters}}
|
||||
assert "$ARGUMENTS" not in content, f"{cmd_file.name} has unprocessed $ARGUMENTS"
|
||||
# Frontmatter sections should be stripped
|
||||
assert "\nscripts:\n" not in content
|
||||
# Check Forge-specific: command references use hyphen notation, not dot notation
|
||||
assert not re.search(r"/speckit\.[a-z]", content), (
|
||||
f"{cmd_file.name} contains dot-notation command reference (/speckit.<cmd>); "
|
||||
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility"
|
||||
)
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan command must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_forge_specific_transformations(self, tmp_path):
|
||||
"""Test Forge-specific processing: name injection and handoffs stripping."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
|
||||
# Check that name field is injected in frontmatter
|
||||
assert "name" in frontmatter, f"{cmd_file.name} missing injected 'name' field in frontmatter"
|
||||
|
||||
# Check that handoffs frontmatter key is stripped
|
||||
assert "handoffs" not in frontmatter, f"{cmd_file.name} has unstripped 'handoffs' key in frontmatter"
|
||||
|
||||
def test_uses_parameters_placeholder(self, tmp_path):
|
||||
"""Verify Forge replaces $ARGUMENTS with {{parameters}} in generated files."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
|
||||
# The registrar_config should specify {{parameters}}
|
||||
assert forge.registrar_config["args"] == "{{parameters}}"
|
||||
|
||||
# Generate files and verify $ARGUMENTS is replaced with {{parameters}}
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
# Check all generated command files
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
# $ARGUMENTS should be replaced with {{parameters}}
|
||||
assert "$ARGUMENTS" not in content, (
|
||||
f"{cmd_file.name} still contains $ARGUMENTS - it should be replaced with {{{{parameters}}}}"
|
||||
)
|
||||
# At least some files should have {{parameters}} (those with user input sections)
|
||||
# We'll check the checklist file specifically as it has a User Input section
|
||||
|
||||
# Verify checklist specifically has {{parameters}} in the User Input section
|
||||
checklist = commands_dir / "speckit.checklist.md"
|
||||
if checklist.exists():
|
||||
content = checklist.read_text(encoding="utf-8")
|
||||
assert "{{parameters}}" in content, (
|
||||
"checklist should contain {{parameters}} in User Input section"
|
||||
)
|
||||
|
||||
def test_command_refs_use_hyphen_notation(self, tmp_path):
|
||||
"""Verify all generated Forge command files use /speckit-foo, not /speckit.foo."""
|
||||
import re
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
files_with_refs = []
|
||||
files_with_dot_refs = []
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
if re.search(r"/speckit-[a-z]", content):
|
||||
files_with_refs.append(cmd_file.name)
|
||||
if re.search(r"/speckit\.[a-z]", content):
|
||||
files_with_dot_refs.append(cmd_file.name)
|
||||
|
||||
assert files_with_dot_refs == [], (
|
||||
f"Files contain dot-notation command references: {files_with_dot_refs}. "
|
||||
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility."
|
||||
)
|
||||
assert len(files_with_refs) > 0, (
|
||||
"Expected at least one generated Forge command to contain /speckit-<cmd> reference, "
|
||||
"but none were found. Check that __SPECKIT_COMMAND_*__ tokens are being resolved."
|
||||
)
|
||||
|
||||
def test_name_field_uses_hyphenated_format(self, tmp_path):
|
||||
"""Verify that injected name fields use hyphenated format (speckit-plan, not speckit.plan)."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
commands_dir = tmp_path / ".forge" / "commands"
|
||||
|
||||
# Check that name fields use hyphenated format
|
||||
registrar = CommandRegistrar()
|
||||
for cmd_file in commands_dir.glob("speckit.*.md"):
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
# Extract the name field from frontmatter using the parser
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
assert "name" in frontmatter, (
|
||||
f"{cmd_file.name} missing injected 'name' field in frontmatter"
|
||||
)
|
||||
name_value = frontmatter["name"]
|
||||
# Name should use hyphens, not dots
|
||||
assert "." not in name_value, (
|
||||
f"{cmd_file.name} has name field with dots: {name_value} "
|
||||
f"(should use hyphens for Forge/ZSH compatibility)"
|
||||
)
|
||||
assert name_value.startswith("speckit-"), (
|
||||
f"{cmd_file.name} name field should start with 'speckit-': {name_value}"
|
||||
)
|
||||
|
||||
|
||||
class TestForgeCommandRegistrar:
|
||||
"""Test CommandRegistrar's Forge-specific name formatting."""
|
||||
|
||||
def test_registrar_formats_extension_command_names_for_forge(self, tmp_path):
|
||||
"""Verify CommandRegistrar converts dot notation to hyphens for Forge."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
# Create a test command with dot notation name
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Test extension command\n"
|
||||
"---\n\n"
|
||||
"Test content with $ARGUMENTS\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# Register with Forge
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.my-extension.example",
|
||||
"file": "commands/example.md"
|
||||
}
|
||||
]
|
||||
|
||||
registered = registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
"test-extension",
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
# Verify registration succeeded
|
||||
assert "speckit.my-extension.example" in registered
|
||||
|
||||
# Check the generated file has hyphenated name in frontmatter
|
||||
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-my-extension-example.md"
|
||||
assert forge_cmd.exists()
|
||||
|
||||
content = forge_cmd.read_text(encoding="utf-8")
|
||||
# Parse frontmatter to validate name field precisely
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
assert "name" in frontmatter, "name field should be injected in frontmatter"
|
||||
# Name field should use hyphens, not dots
|
||||
assert frontmatter["name"] == "speckit-my-extension-example"
|
||||
|
||||
def test_registrar_formats_alias_names_for_forge(self, tmp_path):
|
||||
"""Verify CommandRegistrar converts alias names to hyphens for Forge."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Test command with alias\n"
|
||||
"---\n\n"
|
||||
"Test content\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# Register with Forge including an alias
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.my-extension.example",
|
||||
"file": "commands/example.md",
|
||||
"aliases": ["speckit.my-extension.ex"]
|
||||
}
|
||||
]
|
||||
|
||||
registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
"test-extension",
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
# Check the alias file has hyphenated name in frontmatter
|
||||
alias_file = tmp_path / ".forge" / "commands" / "speckit-my-extension-ex.md"
|
||||
assert alias_file.exists()
|
||||
|
||||
content = alias_file.read_text(encoding="utf-8")
|
||||
# Parse frontmatter to validate alias name field precisely
|
||||
frontmatter, _ = registrar.parse_frontmatter(content)
|
||||
assert "name" in frontmatter, "name field should be injected in alias frontmatter"
|
||||
# Alias name field should also use hyphens
|
||||
assert frontmatter["name"] == "speckit-my-extension-ex"
|
||||
|
||||
def test_registrar_does_not_affect_other_agents(self, tmp_path):
|
||||
"""Verify format_name callback is Forge-specific and doesn't affect other agents."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Create a mock extension command file
|
||||
ext_dir = tmp_path / "extension"
|
||||
ext_dir.mkdir()
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir()
|
||||
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Test command\n"
|
||||
"---\n\n"
|
||||
"Test content with $ARGUMENTS\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
# Register with Kilo Code (standard markdown agent without inject_name)
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.my-extension.example",
|
||||
"file": "commands/example.md"
|
||||
}
|
||||
]
|
||||
|
||||
registrar.register_commands(
|
||||
"kilocode",
|
||||
commands,
|
||||
"test-extension",
|
||||
ext_dir,
|
||||
tmp_path
|
||||
)
|
||||
|
||||
# Kilo Code uses standard markdown format without name injection.
|
||||
# The format_name callback should not be invoked for non-Forge agents.
|
||||
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
|
||||
assert kilocode_cmd.exists()
|
||||
|
||||
content = kilocode_cmd.read_text(encoding="utf-8")
|
||||
# Kilo Code should NOT have a name field injected
|
||||
assert "name:" not in content, (
|
||||
"Kilo Code should not inject name field - format_name callback should be Forge-only"
|
||||
)
|
||||
|
||||
def test_git_extension_command_uses_hyphen_notation(self, tmp_path):
|
||||
"""Verify the git extension's feature command uses /speckit-specify (not /speckit.specify) for Forge."""
|
||||
from pathlib import Path
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
# Locate the real git extension command source file
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
ext_dir = repo_root / "extensions" / "git"
|
||||
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
|
||||
assert cmd_source.exists(), (
|
||||
f"Git extension command source not found at {cmd_source}. "
|
||||
"Ensure extensions/git/commands/speckit.git.feature.md exists."
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{
|
||||
"name": "speckit.git.feature",
|
||||
"file": "commands/speckit.git.feature.md",
|
||||
}
|
||||
]
|
||||
|
||||
registered = registrar.register_commands(
|
||||
"forge",
|
||||
commands,
|
||||
"git",
|
||||
ext_dir,
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert "speckit.git.feature" in registered
|
||||
|
||||
forge_cmd = tmp_path / ".forge" / "commands" / "speckit-git-feature.md"
|
||||
assert forge_cmd.exists(), "Expected Forge command file was not created"
|
||||
|
||||
content = forge_cmd.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" in content, (
|
||||
"Expected '/speckit-specify' (hyphen) in generated Forge git.feature command body, "
|
||||
"but it was not found. Check that __SPECKIT_COMMAND_SPECIFY__ is resolved correctly."
|
||||
)
|
||||
assert "/speckit.specify" not in content, (
|
||||
"Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. "
|
||||
"Forge requires hyphen notation for ZSH compatibility."
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for GeminiIntegration."""
|
||||
|
||||
from .test_integration_base_toml import TomlIntegrationTests
|
||||
|
||||
|
||||
class TestGeminiIntegration(TomlIntegrationTests):
|
||||
KEY = "gemini"
|
||||
FOLDER = ".gemini/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".gemini/commands"
|
||||
@@ -0,0 +1,362 @@
|
||||
"""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
|
||||
|
||||
# -- 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}}"
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
|
||||
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
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan command must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_plan_defines_quickstart_as_validation_guide(self, tmp_path):
|
||||
"""The generated plan command should keep quickstart.md out of implementation scope."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
|
||||
assert "Create quickstart validation guide" in content
|
||||
assert "runnable validation scenarios" in content
|
||||
assert "Do not include full implementation code" in content
|
||||
assert "implementation details belong in `tasks.md` and the implementation phase" in content
|
||||
|
||||
def test_implement_loads_constitution_context(self, tmp_path):
|
||||
"""The generated implement command should load constitution governance context."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
implement_file = tmp_path / ".custom" / "cmds" / "speckit.implement.md"
|
||||
assert implement_file.exists()
|
||||
content = implement_file.read_text(encoding="utf-8")
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_stem",
|
||||
[
|
||||
"analyze",
|
||||
"clarify",
|
||||
"converge",
|
||||
"implement",
|
||||
"plan",
|
||||
"checklist",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
],
|
||||
)
|
||||
def test_command_loads_constitution_context(self, tmp_path, command_stem):
|
||||
"""Every command except constitution must reference constitution.md."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / f"speckit.{command_stem}.md"
|
||||
assert cmd_file.exists(), f"Command file missing: {cmd_file.name}"
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
assert "constitution.md" in content, (
|
||||
f"speckit.{command_stem}.md must reference constitution.md"
|
||||
)
|
||||
|
||||
def test_constitution_command_exists(self, tmp_path):
|
||||
"""The constitution command itself must exist but is not required to load itself."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / "speckit.constitution.md"
|
||||
assert cmd_file.exists()
|
||||
|
||||
# -- CLI --------------------------------------------------------------
|
||||
|
||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||
"""--integration generic without --integration-options 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",
|
||||
])
|
||||
# Generic requires --commands-dir via --integration-options
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --integration-options=--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",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh",
|
||||
], 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() and ".git" not in p.parts
|
||||
)
|
||||
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.converge.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/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/setup-tasks.sh",
|
||||
".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",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
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 --integration-options=--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",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "ps",
|
||||
], 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() and ".git" not in p.parts
|
||||
)
|
||||
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.converge.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/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/setup-tasks.ps1",
|
||||
".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",
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
])
|
||||
assert actual == expected, (
|
||||
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
||||
f"Extra: {sorted(set(actual) - set(expected))}"
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for GooseIntegration."""
|
||||
|
||||
import yaml
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_yaml import YamlIntegrationTests
|
||||
|
||||
|
||||
class TestGooseIntegration(YamlIntegrationTests):
|
||||
KEY = "goose"
|
||||
FOLDER = ".goose/"
|
||||
COMMANDS_SUBDIR = "recipes"
|
||||
REGISTRAR_DIR = ".goose/recipes"
|
||||
|
||||
def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path):
|
||||
# “If a generated Goose recipe uses {{args}} in its prompt, it
|
||||
# must declare a corresponding args parameter.”
|
||||
|
||||
integration = get_integration("goose")
|
||||
assert integration is not None
|
||||
|
||||
manifest = IntegrationManifest("goose", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
recipe_files = [path for path in created if path.suffix == ".yaml"]
|
||||
assert recipe_files
|
||||
|
||||
for recipe_file in recipe_files:
|
||||
data = yaml.safe_load(recipe_file.read_text(encoding="utf-8"))
|
||||
|
||||
if "{{args}}" not in data["prompt"]:
|
||||
continue
|
||||
|
||||
assert any(
|
||||
param.get("key") == "args"
|
||||
for param in data.get("parameters", [])
|
||||
), f"{recipe_file} uses {{{{args}}}} but does not declare args"
|
||||
|
||||
|
||||
class TestGooseCommandPlaceholderResolution:
|
||||
"""register_commands must resolve skill placeholders for the yaml branch.
|
||||
|
||||
The yaml (Goose recipe) branch previously skipped
|
||||
resolve_skill_placeholders / _convert_argument_placeholder that the
|
||||
markdown and toml branches apply, so extension/preset command bodies
|
||||
kept literal {SCRIPT} / __AGENT__ / repo-relative paths.
|
||||
"""
|
||||
|
||||
def test_register_commands_resolves_placeholders_in_recipe(self, tmp_path):
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
ext_dir = tmp_path / "extension"
|
||||
cmd_dir = ext_dir / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
cmd_file = cmd_dir / "example.md"
|
||||
cmd_file.write_text(
|
||||
"---\n"
|
||||
"description: Placeholder command\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/do.sh\n"
|
||||
" ps: scripts/powershell/do.ps1\n"
|
||||
"---\n\n"
|
||||
"Run {SCRIPT} for agent __AGENT__ with $ARGUMENTS.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "speckit.example", "file": "commands/example.md"}]
|
||||
registrar.register_commands("goose", commands, "test-ext", ext_dir, tmp_path)
|
||||
|
||||
recipe = tmp_path / ".goose" / "recipes" / "speckit.example.yaml"
|
||||
assert recipe.exists(), "goose recipe should be generated"
|
||||
# Parse the recipe and assert the prompt actually got the correct
|
||||
# replacements — not merely that the literal tokens are absent (which
|
||||
# a wrong-but-token-free output could also satisfy).
|
||||
data = yaml.safe_load(recipe.read_text(encoding="utf-8"))
|
||||
prompt = data["prompt"]
|
||||
assert ".specify/scripts/" in prompt # {SCRIPT} -> resolved script path
|
||||
assert "agent goose" in prompt # __AGENT__ -> agent name
|
||||
assert "{{args}}" in prompt # $ARGUMENTS -> goose args token
|
||||
# And the raw placeholders must not survive.
|
||||
assert "{SCRIPT}" not in prompt
|
||||
assert "__AGENT__" not in prompt
|
||||
assert "$ARGUMENTS" not in prompt
|
||||
@@ -0,0 +1,390 @@
|
||||
"""Tests for HermesIntegration.
|
||||
|
||||
Hermes is special among SkillsIntegration subclasses: it writes skills
|
||||
to ``~/.hermes/skills/`` (global) rather than the project-local
|
||||
``.hermes/skills/`` directory. A project-local marker (empty directory)
|
||||
is created so extension commands (e.g. git) can detect Hermes.
|
||||
|
||||
All tests that touch ``~/.hermes/`` use ``monkeypatch`` to isolate
|
||||
``Path.home()`` to a temp directory so the test suite is hermetic and
|
||||
non-destructive to a developer's real Hermes installation.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
def _fake_home(tmp_path: Path) -> Path:
|
||||
"""Create and return an isolated home directory under *tmp_path*."""
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
return home
|
||||
|
||||
|
||||
class TestHermesIntegration(SkillsIntegrationTests):
|
||||
KEY = "hermes"
|
||||
FOLDER = ".hermes/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = "~/.hermes/skills"
|
||||
|
||||
# -- Hermes-specific setup: skills go to ~/.hermes/skills/ -------------
|
||||
|
||||
def test_setup_writes_to_global_skills_dir(self, tmp_path, monkeypatch):
|
||||
"""Skills are written to ~/.hermes/skills/, not project-local."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if "scripts" not in f.parts]
|
||||
|
||||
assert len(skill_files) > 0, "No skill files were created"
|
||||
for f in skill_files:
|
||||
# Every skill file should be under ~/.hermes/skills/speckit-*/
|
||||
expected_prefix = str(home / ".hermes" / "skills")
|
||||
assert str(f).startswith(expected_prefix), (
|
||||
f"{f} is not under ~/.hermes/skills/"
|
||||
)
|
||||
|
||||
def test_local_marker_dir_created(self, tmp_path, monkeypatch):
|
||||
"""Project-local .hermes/skills/ should exist but be empty."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
marker = tmp_path / ".hermes" / "skills"
|
||||
assert marker.is_dir(), "Marker directory was not created"
|
||||
# Should be empty (no SKILL.md files)
|
||||
children = list(marker.iterdir())
|
||||
assert children == [], f"Marker directory should be empty, got: {children}"
|
||||
|
||||
# -- Override shared tests that assume project-local skills ------------
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes writes to global, not project-local."""
|
||||
self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch)
|
||||
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch):
|
||||
"""The core plan skill must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
# Find the plan skill in global ~/.hermes/skills/
|
||||
plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists(), f"Plan skill {plan_file} not created globally"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
|
||||
)
|
||||
|
||||
def test_all_files_tracked_in_manifest(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes does not track skills in the project manifest
|
||||
since they live globally. Only project-local files (scripts,
|
||||
templates, context) are tracked."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
for f in created:
|
||||
# Global files (in ~/.hermes/) are not tracked in manifest
|
||||
if str(f).startswith(str(home)):
|
||||
continue
|
||||
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, monkeypatch):
|
||||
"""Override: Hermes uninstall removes global skills + local marker."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
assert len(created) > 0
|
||||
m.save()
|
||||
# All SKILL.md files should exist globally
|
||||
for f in created:
|
||||
if "SKILL.md" in str(f):
|
||||
assert f.exists(), f"{f} does not exist"
|
||||
# Global skills are removed on teardown without needing force
|
||||
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||
for f in created:
|
||||
if "SKILL.md" in str(f):
|
||||
assert not f.exists(), f"{f} should have been removed"
|
||||
# Local marker should be gone
|
||||
assert not (tmp_path / ".hermes" / "skills").exists()
|
||||
|
||||
def test_modified_file_survives_uninstall(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes global skills are ALWAYS removed on uninstall
|
||||
(they live outside the project root and aren't hash-tracked in the
|
||||
manifest), so a modified global skill is still removed — matching
|
||||
the standard behaviour where all integration files are cleaned up."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
# Pick a global skill file
|
||||
skill_files = [f for f in created if "SKILL.md" in str(f)]
|
||||
assert len(skill_files) > 0
|
||||
modified_file = skill_files[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
removed, skipped = i.uninstall(tmp_path, m)
|
||||
assert not modified_file.exists(), (
|
||||
"Modified global skill should be removed on teardown (standard behaviour)"
|
||||
)
|
||||
|
||||
def test_modified_global_skill_removed_on_teardown(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes global skills are removed on uninstall regardless
|
||||
of the force flag, matching standard integration behaviour."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.install(tmp_path, m)
|
||||
m.save()
|
||||
# Pick a global skill file
|
||||
skill_files = [f for f in created if "SKILL.md" in str(f)]
|
||||
assert len(skill_files) > 0
|
||||
modified_file = skill_files[0]
|
||||
modified_file.write_text("user modified this", encoding="utf-8")
|
||||
# Global skills are removed on teardown regardless of force flag
|
||||
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||
assert not modified_file.exists(), (
|
||||
"Modified global skill should be removed on teardown (standard behaviour)"
|
||||
)
|
||||
|
||||
def test_pre_existing_skills_not_removed(self, tmp_path, monkeypatch):
|
||||
"""Pre-existing non-speckit global skills should survive Hermes uninstall."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
# Create a foreign skill in the global dir first
|
||||
global_skills_dir = i._hermes_home_skills_dir()
|
||||
foreign_dir = global_skills_dir / "other-tool"
|
||||
foreign_dir.mkdir(parents=True, exist_ok=True)
|
||||
(foreign_dir / "SKILL.md").write_text("# Foreign skill\n")
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
|
||||
# Run teardown to verify foreign skill survives uninstall
|
||||
i.teardown(tmp_path, m)
|
||||
|
||||
assert (foreign_dir / "SKILL.md").exists(), (
|
||||
"Foreign skill was removed by teardown"
|
||||
)
|
||||
|
||||
def test_hook_sections_explain_dotted_command_conversion(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes skills live in global ~/.hermes/skills/."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
specify_skill = home / ".hermes" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should explain dotted hook command conversion"
|
||||
)
|
||||
assert content.count("replace dots") == content.count(
|
||||
"- For each executable hook, output the following"
|
||||
)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path, monkeypatch):
|
||||
"""Override: Hermes init produces no local SKILL.md files,
|
||||
only the empty .hermes/skills/ marker."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-sh-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = Path.cwd()
|
||||
import os
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "sh", "--ignore-agent-tools",
|
||||
], 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()
|
||||
)
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
# Ensure the marker exists (empty dir won't appear in file listing)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
|
||||
def test_complete_file_inventory_ps(self, tmp_path, monkeypatch):
|
||||
"""Override: Same as sh variant but for PowerShell script type."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"inventory-ps-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = Path.cwd()
|
||||
import os
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY,
|
||||
"--script", "ps", "--ignore-agent-tools",
|
||||
], 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()
|
||||
)
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
|
||||
def test_install_uninstall_cleanup(self, tmp_path, monkeypatch):
|
||||
"""Verify global skills are cleaned and local marker is removed."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
|
||||
# Verify global skills exist
|
||||
global_skills = [
|
||||
f for f in created
|
||||
if "SKILL.md" in str(f)
|
||||
and str(f).startswith(str(home / ".hermes"))
|
||||
]
|
||||
assert len(global_skills) > 0
|
||||
for f in global_skills:
|
||||
assert f.exists()
|
||||
|
||||
# Verify local marker exists
|
||||
assert (tmp_path / ".hermes" / "skills").is_dir()
|
||||
|
||||
# Teardown — global skills removed without needing force=True
|
||||
removed, skipped = i.teardown(tmp_path, m, force=False)
|
||||
|
||||
# Global skills removed
|
||||
for f in global_skills:
|
||||
assert not f.exists(), f"{f} should have been removed"
|
||||
|
||||
# Local marker removed
|
||||
assert not (tmp_path / ".hermes" / "skills").exists(), (
|
||||
"Local marker should be removed on teardown"
|
||||
)
|
||||
|
||||
|
||||
class TestHermesInitFlow:
|
||||
"""--integration hermes creates expected files."""
|
||||
|
||||
def test_integration_hermes_creates_global_skills(self, tmp_path, monkeypatch):
|
||||
"""--integration hermes should create global skills and a local marker."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(app, [
|
||||
"init", str(target),
|
||||
"--integration", "hermes",
|
||||
"--ignore-agent-tools",
|
||||
"--script", "sh",
|
||||
])
|
||||
|
||||
assert result.exit_code == 0, f"init --integration hermes failed: {result.output}"
|
||||
# Skills should be in global ~/.hermes/skills/
|
||||
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
# Local marker should exist
|
||||
assert (target / ".hermes" / "skills").is_dir()
|
||||
# No core SKILL.md files in project-local dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
local_skills = [
|
||||
d for d in (target / ".hermes" / "skills").iterdir()
|
||||
if "agent-context" not in d.name
|
||||
]
|
||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||
|
||||
|
||||
class TestHermesBuildExecArgs:
|
||||
"""CLI dispatch argv, including the operator extra-args env hook."""
|
||||
|
||||
def test_build_exec_args_default_shape(self):
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi", output_json=True) == [
|
||||
"hermes", "chat", "-Q", "--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
|
||||
def test_build_exec_args_honors_extra_args(self, monkeypatch):
|
||||
"""SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS is injected before the
|
||||
canonical -m/--json/-s/-q flags (same env hook as codex/opencode/
|
||||
devin; hermes previously skipped _apply_extra_args_env_var entirely).
|
||||
"""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXTRA_ARGS", "--temperature 0.2"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
args = i.build_exec_args("/speckit-plan hi", output_json=True)
|
||||
assert args == [
|
||||
"hermes", "chat", "-Q", "--temperature", "0.2",
|
||||
"--json", "-s", "speckit-plan", "-q", "hi",
|
||||
]
|
||||
# Injected before the canonical flags so it can't displace them.
|
||||
assert args.index("--temperature") < args.index("--json")
|
||||
assert args.index("--temperature") < args.index("-s")
|
||||
|
||||
def test_build_exec_args_honors_executable_override(self, monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_HERMES_EXECUTABLE", "/custom/hermes"
|
||||
)
|
||||
i = get_integration("hermes")
|
||||
assert i.build_exec_args("/speckit-plan hi")[0] == "/custom/hermes"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for JunieIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestJunieIntegration(MarkdownIntegrationTests):
|
||||
KEY = "junie"
|
||||
FOLDER = ".junie/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".junie/commands"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for KilocodeIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestKilocodeIntegration(MarkdownIntegrationTests):
|
||||
KEY = "kilocode"
|
||||
FOLDER = ".kilocode/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".kilocode/workflows"
|
||||
@@ -0,0 +1,386 @@
|
||||
"""Tests for KimiIntegration — skills integration with legacy migration."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.kimi import (
|
||||
_migrate_legacy_kimi_dotted_skills,
|
||||
_migrate_legacy_kimi_skills_dir,
|
||||
)
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
def _symlink_or_skip(
|
||||
link: Path, target: Path, *, target_is_directory: bool = False
|
||||
) -> None:
|
||||
"""Create *link* pointing at *target*, skipping the test if unsupported.
|
||||
|
||||
Symlink creation fails on Windows without the create-symlink privilege and
|
||||
in some restricted CI sandboxes. The symlink-safety tests below assert
|
||||
behavior that only matters when symlinks exist, so skip (rather than error)
|
||||
when the platform cannot create them.
|
||||
"""
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=target_is_directory)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
|
||||
class TestKimiIntegration(SkillsIntegrationTests):
|
||||
KEY = "kimi"
|
||||
FOLDER = ".kimi-code/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".kimi-code/skills"
|
||||
|
||||
|
||||
class TestKimiOptions:
|
||||
"""Kimi declares --skills and --migrate-legacy options."""
|
||||
|
||||
def test_migrate_legacy_option(self):
|
||||
i = get_integration("kimi")
|
||||
opts = i.options()
|
||||
migrate_opts = [o for o in opts if o.name == "--migrate-legacy"]
|
||||
assert len(migrate_opts) == 1
|
||||
assert migrate_opts[0].is_flag is True
|
||||
assert migrate_opts[0].default is False
|
||||
|
||||
|
||||
class TestKimiLegacyMigration:
|
||||
"""Test Kimi dotted → hyphenated skill directory migration."""
|
||||
|
||||
def test_migrate_dotted_to_hyphenated(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text("# Plan Skill\n")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 1
|
||||
assert removed == 0
|
||||
assert not legacy.exists()
|
||||
assert (skills_dir / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_skip_when_target_exists_different_content(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text("# Old\n")
|
||||
|
||||
target = skills_dir / "speckit-plan"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text("# New (different)\n")
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy.exists()
|
||||
assert target.exists()
|
||||
|
||||
def test_remove_when_target_exists_same_content(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
content = "# Identical\n"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text(content)
|
||||
|
||||
target = skills_dir / "speckit-plan"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(content)
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 1
|
||||
assert not legacy.exists()
|
||||
assert target.exists()
|
||||
|
||||
def test_preserve_legacy_with_extra_files(self, tmp_path):
|
||||
skills_dir = tmp_path / ".kimi" / "skills"
|
||||
content = "# Same\n"
|
||||
legacy = skills_dir / "speckit.plan"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text(content)
|
||||
(legacy / "extra.md").write_text("user file")
|
||||
|
||||
target = skills_dir / "speckit-plan"
|
||||
target.mkdir(parents=True)
|
||||
(target / "SKILL.md").write_text(content)
|
||||
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(skills_dir)
|
||||
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
assert legacy.exists()
|
||||
|
||||
def test_nonexistent_dir_returns_zeros(self, tmp_path):
|
||||
migrated, removed = _migrate_legacy_kimi_dotted_skills(
|
||||
tmp_path / ".kimi" / "skills"
|
||||
)
|
||||
assert migrated == 0
|
||||
assert removed == 0
|
||||
|
||||
def test_setup_migrate_legacy_moves_old_skills_dir(self, tmp_path):
|
||||
"""--migrate-legacy moves hyphenated skills from .kimi/skills to .kimi-code/skills."""
|
||||
i = get_integration("kimi")
|
||||
|
||||
old_skills_dir = tmp_path / ".kimi" / "skills"
|
||||
new_skills_dir = tmp_path / ".kimi-code" / "skills"
|
||||
legacy = old_skills_dir / "speckit-oldcmd"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text("# Legacy\n")
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
assert not legacy.exists()
|
||||
assert not old_skills_dir.exists()
|
||||
assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
|
||||
# New skills from templates should also exist
|
||||
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
def test_setup_with_migrate_legacy_option(self, tmp_path):
|
||||
"""KimiIntegration.setup() with --migrate-legacy migrates dotted dirs."""
|
||||
i = get_integration("kimi")
|
||||
|
||||
old_skills_dir = tmp_path / ".kimi" / "skills"
|
||||
new_skills_dir = tmp_path / ".kimi-code" / "skills"
|
||||
legacy = old_skills_dir / "speckit.oldcmd"
|
||||
legacy.mkdir(parents=True)
|
||||
(legacy / "SKILL.md").write_text("# Legacy\n")
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
assert not legacy.exists()
|
||||
assert (new_skills_dir / "speckit-oldcmd" / "SKILL.md").exists()
|
||||
# New skills from templates should also exist
|
||||
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestKimiTeardownLegacyCleanup:
|
||||
"""teardown() removes leftover legacy .kimi/skills/ directories."""
|
||||
|
||||
def test_teardown_removes_legacy_speckit_skills(self, tmp_path):
|
||||
i = get_integration("kimi")
|
||||
|
||||
legacy_skill = tmp_path / ".kimi" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
legacy_skill.parent.mkdir(parents=True)
|
||||
legacy_skill.write_text(
|
||||
"---\n"
|
||||
"name: \"speckit-plan\"\n"
|
||||
"description: \"Plan workflow\"\n"
|
||||
"metadata:\n"
|
||||
" author: \"github-spec-kit\"\n"
|
||||
" source: \"templates/commands/plan.md\"\n"
|
||||
"---\n"
|
||||
)
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.teardown(tmp_path, m)
|
||||
|
||||
assert not legacy_skill.exists()
|
||||
assert not (tmp_path / ".kimi" / "skills").exists()
|
||||
|
||||
def test_teardown_preserves_user_skills_in_legacy_dir(self, tmp_path):
|
||||
i = get_integration("kimi")
|
||||
|
||||
user_skill = tmp_path / ".kimi" / "skills" / "my-custom" / "SKILL.md"
|
||||
user_skill.parent.mkdir(parents=True)
|
||||
user_skill.write_text("# My custom skill\n")
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.teardown(tmp_path, m)
|
||||
|
||||
assert user_skill.exists()
|
||||
|
||||
|
||||
class TestKimiCommandInvocation:
|
||||
"""Kimi dispatch must use the native ``/skill:`` slash command."""
|
||||
|
||||
def test_build_command_invocation_uses_skill_prefix(self):
|
||||
i = get_integration("kimi")
|
||||
assert i.build_command_invocation("specify") == "/skill:speckit-specify"
|
||||
assert i.build_command_invocation("speckit.plan") == "/skill:speckit-plan"
|
||||
|
||||
def test_build_command_invocation_dotted_extension(self):
|
||||
i = get_integration("kimi")
|
||||
assert (
|
||||
i.build_command_invocation("speckit.git.commit")
|
||||
== "/skill:speckit-git-commit"
|
||||
)
|
||||
|
||||
def test_build_command_invocation_appends_args(self):
|
||||
i = get_integration("kimi")
|
||||
assert (
|
||||
i.build_command_invocation("specify", "my feature")
|
||||
== "/skill:speckit-specify my feature"
|
||||
)
|
||||
|
||||
|
||||
class TestKimiLegacySymlinkSafety:
|
||||
"""Legacy migration/cleanup must not follow symlinks out of the project."""
|
||||
|
||||
def test_migrate_skips_symlinked_legacy_skills_dir(self, tmp_path):
|
||||
# An attacker-controlled directory outside the project root. Use a
|
||||
# non-template skill name so a successful migration would be visible
|
||||
# (the bundled templates never create "speckit-evillegacy").
|
||||
outside = tmp_path / "outside"
|
||||
(outside / "speckit-evillegacy").mkdir(parents=True)
|
||||
(outside / "speckit-evillegacy" / "SKILL.md").write_text("# evil\n")
|
||||
|
||||
project = tmp_path / "project"
|
||||
(project / ".kimi").mkdir(parents=True)
|
||||
# .kimi/skills is a symlink to the outside directory.
|
||||
_symlink_or_skip(
|
||||
project / ".kimi" / "skills", outside, target_is_directory=True
|
||||
)
|
||||
|
||||
i = get_integration("kimi")
|
||||
m = IntegrationManifest("kimi", project)
|
||||
i.setup(project, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
# Outside content must be untouched (not moved into .kimi-code).
|
||||
assert (outside / "speckit-evillegacy" / "SKILL.md").exists()
|
||||
assert not (
|
||||
project / ".kimi-code" / "skills" / "speckit-evillegacy"
|
||||
).exists()
|
||||
|
||||
def test_teardown_skips_symlinked_legacy_skills_dir(self, tmp_path):
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
keep = outside / "keep.txt"
|
||||
keep.write_text("important\n")
|
||||
|
||||
project = tmp_path / "project"
|
||||
(project / ".kimi").mkdir(parents=True)
|
||||
_symlink_or_skip(
|
||||
project / ".kimi" / "skills", outside, target_is_directory=True
|
||||
)
|
||||
|
||||
i = get_integration("kimi")
|
||||
m = IntegrationManifest("kimi", project)
|
||||
i.teardown(project, m)
|
||||
|
||||
# The symlink target and its contents must survive teardown.
|
||||
assert keep.exists()
|
||||
|
||||
def test_migrate_skips_symlinked_legacy_parent_dir(self, tmp_path):
|
||||
# `.kimi` is itself a symlink to the project root, so `.kimi/skills`
|
||||
# resolves to `./skills` — an unrelated in-tree directory. Even though
|
||||
# the resolved path stays inside the project, migration must not
|
||||
# operate on it because a path component is a symlink.
|
||||
project = tmp_path / "project"
|
||||
unrelated = project / "skills" / "speckit-evillegacy"
|
||||
unrelated.mkdir(parents=True)
|
||||
(unrelated / "SKILL.md").write_text("# unrelated\n")
|
||||
# .kimi -> project root, so .kimi/skills == ./skills.
|
||||
_symlink_or_skip(project / ".kimi", project, target_is_directory=True)
|
||||
|
||||
i = get_integration("kimi")
|
||||
m = IntegrationManifest("kimi", project)
|
||||
i.setup(project, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
# The unrelated ./skills content must be untouched.
|
||||
assert (unrelated / "SKILL.md").exists()
|
||||
assert not (
|
||||
project / ".kimi-code" / "skills" / "speckit-evillegacy"
|
||||
).exists()
|
||||
|
||||
def test_teardown_skips_symlinked_legacy_parent_dir(self, tmp_path):
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
# Looks Speckit-generated, so only the symlink check protects it.
|
||||
unrelated = project / "skills" / "speckit-evillegacy"
|
||||
unrelated.mkdir(parents=True)
|
||||
(unrelated / "SKILL.md").write_text(
|
||||
"---\nmetadata:\n author: github-spec-kit\n---\n# x\n"
|
||||
)
|
||||
_symlink_or_skip(project / ".kimi", project, target_is_directory=True)
|
||||
|
||||
i = get_integration("kimi")
|
||||
m = IntegrationManifest("kimi", project)
|
||||
i.teardown(project, m)
|
||||
|
||||
# The unrelated ./skills content must survive teardown.
|
||||
assert (unrelated / "SKILL.md").exists()
|
||||
|
||||
def test_setup_rejects_symlinked_destination_before_writing(self, tmp_path):
|
||||
# `.kimi-code` is a symlink to the project root, so the skills
|
||||
# destination `.kimi-code/skills` resolves to `./skills` — an
|
||||
# unintended in-tree location. base setup() only rejects a
|
||||
# destination that escapes the project root, so without the
|
||||
# pre-check it would write SKILL.md files into `./skills`. setup()
|
||||
# must refuse before any write occurs.
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_symlink_or_skip(project / ".kimi-code", project, target_is_directory=True)
|
||||
|
||||
i = get_integration("kimi")
|
||||
m = IntegrationManifest("kimi", project)
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
i.setup(project, m)
|
||||
|
||||
# Nothing was written into the unintended `./skills` location.
|
||||
assert not (project / "skills").exists()
|
||||
|
||||
def test_migrate_skips_symlinked_target_dir(self, tmp_path):
|
||||
# The destination `.kimi-code/skills/speckit-foo` already exists but is
|
||||
# a symlink to a directory outside the project. Migration compares
|
||||
# SKILL.md bytes to decide whether to drop the legacy copy; it must not
|
||||
# follow the symlinked target dir to read SKILL.md from outside.
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "SKILL.md").write_text("# shared\n")
|
||||
|
||||
project = tmp_path / "project"
|
||||
legacy = project / ".kimi" / "skills" / "speckit-foo"
|
||||
legacy.mkdir(parents=True)
|
||||
# Identical bytes: without the symlink guard the legacy dir would be
|
||||
# removed after following the link out of the project.
|
||||
(legacy / "SKILL.md").write_text("# shared\n")
|
||||
|
||||
target = project / ".kimi-code" / "skills" / "speckit-foo"
|
||||
target.parent.mkdir(parents=True)
|
||||
_symlink_or_skip(target, outside, target_is_directory=True)
|
||||
|
||||
_migrate_legacy_kimi_skills_dir(
|
||||
project / ".kimi" / "skills", project / ".kimi-code" / "skills"
|
||||
)
|
||||
|
||||
# Legacy copy is preserved (migration refused to follow the symlink),
|
||||
# and the outside target is untouched.
|
||||
assert (legacy / "SKILL.md").exists()
|
||||
assert (outside / "SKILL.md").exists()
|
||||
|
||||
class TestKimiNextSteps:
|
||||
"""CLI output tests for kimi next-steps display."""
|
||||
|
||||
def test_next_steps_show_skill_invocation(self, tmp_path):
|
||||
"""Kimi next-steps guidance should display /skill:speckit-* usage."""
|
||||
import os
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "kimi-next-steps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "kimi",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "/skill:speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
assert "Optional skills that you can use for your specs" in result.output
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Tests for KiroCliIntegration."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.kiro_cli import _KIRO_ARG_FALLBACK
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
# Regex shapes that indicate a value is a placeholder token, not prose.
|
||||
# Covers Bash ($VAR, ${VAR}, ${VAR:-default}), Mustache/Handlebars/Jinja
|
||||
# ({{var}}, {{{var}}}), Liquid/Jinja control ({% ... %}), Python str.format /
|
||||
# .NET ({var}, {0}), angle-bracket (<var>), and Windows-style (%VAR%).
|
||||
# Anchored to the FULL STRING so legitimate prose mentioning a placeholder
|
||||
# (e.g. "the {{magic}} of placeholders") is not flagged. The Liquid pattern
|
||||
# is anchored to the START so multi-tag templates fire while mid-sentence
|
||||
# {%-quotation does not.
|
||||
_PLACEHOLDER_TOKEN_PATTERNS = (
|
||||
re.compile(r"^\$\w+$"), # $ARGUMENTS, $args
|
||||
re.compile(r"^\$\{\w+(?:[:\-+?][^}]*)?\}$"), # ${ARGS}, ${ARGS:-default}
|
||||
re.compile(r"^\{\{\{?\s*\w+(\s*[|.][^}]*)?\s*\}?\}\}$"), # {{var}} {{{var}}} {{x|y}}
|
||||
re.compile(r"^\{%"), # {% if x %}{{ x }}{% endif %}
|
||||
re.compile(r"^<\w+>$"), # <args>
|
||||
re.compile(r"^%\w+%$"), # %USERNAME%
|
||||
re.compile(r"^\{(?:\d+|[a-zA-Z_]\w*)(?:[.\[][^}]*)?(?:![rsa])?(?::[^}]*)?\}$"), # {0}, {var}, {0:>5}
|
||||
)
|
||||
|
||||
|
||||
def _looks_like_placeholder_token(value: str) -> bool:
|
||||
"""Return True if *value* matches a known placeholder-token shape."""
|
||||
if not value:
|
||||
return False
|
||||
return any(p.search(value) for p in _PLACEHOLDER_TOKEN_PATTERNS)
|
||||
|
||||
|
||||
class TestKiroCliIntegration(MarkdownIntegrationTests):
|
||||
KEY = "kiro-cli"
|
||||
FOLDER = ".kiro/"
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".kiro/prompts"
|
||||
|
||||
def test_registrar_config(self):
|
||||
"""Override base assertion: kiro-cli uses a prose fallback for args
|
||||
because Kiro CLI file-based prompts do not natively substitute
|
||||
``$ARGUMENTS`` (see issue #1926 / kirodotdev/Kiro#4141). The
|
||||
regression-guard load is carried by the two layer tests below
|
||||
(exact-fallback + placeholder-shape rejection)."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
|
||||
assert i.registrar_config["format"] == "markdown"
|
||||
assert i.registrar_config["extension"] == ".md"
|
||||
|
||||
def test_registrar_config_args_is_exact_prose_fallback(self):
|
||||
"""Layer 1 — pin the exact fallback so wording drift requires a
|
||||
deliberate paired commit (production constant + test update)."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.registrar_config["args"] == _KIRO_ARG_FALLBACK, (
|
||||
f"args drifted from the pinned fallback constant. "
|
||||
f"Got: {i.registrar_config['args']!r}; expected: {_KIRO_ARG_FALLBACK!r}. "
|
||||
f"If the wording change is intentional, update _KIRO_ARG_FALLBACK and "
|
||||
f"this test together."
|
||||
)
|
||||
|
||||
def test_registrar_config_args_does_not_look_like_a_placeholder_token(self):
|
||||
"""Layer 2 — independent regression guard: even if someone bypasses
|
||||
layer-1 by changing both constant and test, the value still must not
|
||||
look like ANY placeholder token shape ($X, ${X}, {{X}}, <X>, %X%, {0},
|
||||
{% %}). Catches the class of regression Copilot called out: a swap
|
||||
from $ARGUMENTS to $INPUT or {{userMessage}} would fail this test
|
||||
even if it accidentally passed layer 1."""
|
||||
i = get_integration(self.KEY)
|
||||
args = i.registrar_config["args"]
|
||||
assert not _looks_like_placeholder_token(args), (
|
||||
f"registrar_config['args'] = {args!r} matches a known placeholder-"
|
||||
f"token shape — Kiro CLI does not substitute placeholders so this "
|
||||
f"would reach the model verbatim and break the prompt (issue #1926). "
|
||||
f"Use a prose fallback instead."
|
||||
)
|
||||
|
||||
def test_rendered_prompts_do_not_contain_raw_arguments(self, tmp_path):
|
||||
"""Rendered Kiro prompt files must NOT contain the raw ``$ARGUMENTS``
|
||||
token — Kiro CLI does not substitute it, so the literal would reach
|
||||
the model and break the prompt (issue #1926)."""
|
||||
integration = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
prompts_dir = tmp_path / self.REGISTRAR_DIR
|
||||
rendered = list(prompts_dir.glob("*.md"))
|
||||
assert rendered, "expected at least one rendered prompt file"
|
||||
|
||||
offenders = [
|
||||
p.name for p in rendered if "$ARGUMENTS" in p.read_text(encoding="utf-8")
|
||||
]
|
||||
assert offenders == [], (
|
||||
f"these rendered prompts still contain the raw $ARGUMENTS token: {offenders}"
|
||||
)
|
||||
|
||||
def test_rendered_prompts_contain_kiro_arg_placeholder(self, tmp_path):
|
||||
"""The chosen kiro-cli args fallback string must end up in at least
|
||||
one rendered prompt (proves substitution actually fired, not just
|
||||
that $ARGUMENTS was removed). Imports the fallback constant directly
|
||||
instead of reading the field back so the test stays independent of
|
||||
the integration's own config — even if the registrar_config['args']
|
||||
regresses, this test still verifies the FALLBACK STRING is in the
|
||||
rendered output."""
|
||||
integration = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
expected = _KIRO_ARG_FALLBACK
|
||||
prompts_dir = tmp_path / self.REGISTRAR_DIR
|
||||
contents = "\n".join(
|
||||
p.read_text(encoding="utf-8") for p in prompts_dir.glob("*.md")
|
||||
)
|
||||
assert expected in contents, (
|
||||
f"none of the rendered prompts contain the configured args fallback "
|
||||
f"({expected!r})"
|
||||
)
|
||||
|
||||
|
||||
class TestKiroIntegration:
|
||||
"""--integration kiro-cli creates expected files."""
|
||||
|
||||
def test_integration_kiro_cli_creates_files(self, tmp_path):
|
||||
"""--integration kiro-cli should create files in .kiro/prompts."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
target = tmp_path / "kiro-proj"
|
||||
target.mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(target)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "kiro-cli",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (target / ".kiro" / "prompts" / "speckit.plan.md").exists()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for LingmaIntegration."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestLingmaIntegration(SkillsIntegrationTests):
|
||||
KEY = "lingma"
|
||||
FOLDER = ".lingma/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".lingma/skills"
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tests for OmpIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestOmpIntegration(MarkdownIntegrationTests):
|
||||
KEY = "omp"
|
||||
FOLDER = ".omp/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".omp/commands"
|
||||
|
||||
def test_build_exec_args_uses_omp_json_mode(self):
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
args = i.build_exec_args(
|
||||
"/speckit.specify Build auth",
|
||||
model="gpt-5",
|
||||
)
|
||||
|
||||
assert args == [
|
||||
"omp",
|
||||
"--print",
|
||||
"--model",
|
||||
"gpt-5",
|
||||
"--mode",
|
||||
"json",
|
||||
"/speckit.specify Build auth",
|
||||
]
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Tests for OpencodeIntegration."""
|
||||
|
||||
import warnings
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestOpencodeIntegration(MarkdownIntegrationTests):
|
||||
KEY = "opencode"
|
||||
FOLDER = ".opencode/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".opencode/commands"
|
||||
|
||||
def test_build_exec_args_uses_run_command_dispatch(self):
|
||||
integration = get_integration(self.KEY)
|
||||
|
||||
args = integration.build_exec_args(
|
||||
"/speckit.specify build a login page",
|
||||
output_json=False,
|
||||
)
|
||||
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--command",
|
||||
"speckit.specify",
|
||||
"build a login page",
|
||||
]
|
||||
assert "-p" not in args
|
||||
assert "--output-format" not in args
|
||||
|
||||
def test_build_exec_args_maps_model_and_json_flags(self):
|
||||
integration = get_integration(self.KEY)
|
||||
|
||||
args = integration.build_exec_args(
|
||||
"/speckit.plan add OAuth",
|
||||
model="anthropic/claude-sonnet-4",
|
||||
output_json=True,
|
||||
)
|
||||
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--command",
|
||||
"speckit.plan",
|
||||
"-m",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"--format",
|
||||
"json",
|
||||
"add OAuth",
|
||||
]
|
||||
|
||||
def test_build_exec_args_keeps_plain_prompt_dispatch(self):
|
||||
integration = get_integration(self.KEY)
|
||||
|
||||
args = integration.build_exec_args("explain this repository", output_json=False)
|
||||
|
||||
assert args == ["opencode", "run", "explain this repository"]
|
||||
|
||||
def test_registrar_config_has_legacy_dir(self):
|
||||
integration = get_integration(self.KEY)
|
||||
assert integration.registrar_config["legacy_dir"] == ".opencode/command"
|
||||
|
||||
def test_legacy_dir_extension_registration(self, tmp_path):
|
||||
"""Extensions register in legacy .opencode/command/ with a warning."""
|
||||
# Seed a legacy project with only .opencode/command/
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "speckit.specify.md").write_text("# existing", encoding="utf-8")
|
||||
|
||||
# Create a source command file for the registrar
|
||||
src_dir = tmp_path / "_ext_src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "myext.md").write_text(
|
||||
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "speckit.myext", "file": "myext.md"}]
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
results = registrar.register_commands_for_all_agents(
|
||||
commands, "test-ext", src_dir, tmp_path,
|
||||
)
|
||||
|
||||
# Should have registered in the legacy directory
|
||||
assert "opencode" in results
|
||||
assert (legacy_dir / "speckit.myext.md").exists()
|
||||
# Canonical directory should NOT have been created
|
||||
assert not (tmp_path / ".opencode" / "commands").exists()
|
||||
# Should have emitted a deprecation warning
|
||||
opencode_warnings = [
|
||||
w for w in caught
|
||||
if "legacy" in str(w.message) and "opencode" in str(w.message)
|
||||
]
|
||||
assert len(opencode_warnings) == 1, (
|
||||
f"Expected exactly 1 legacy-dir warning, got {len(opencode_warnings)}"
|
||||
)
|
||||
assert "specify integration upgrade" in str(opencode_warnings[0].message)
|
||||
|
||||
def test_legacy_dir_unregister(self, tmp_path):
|
||||
"""Unregister finds commands in legacy .opencode/command/ dir."""
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
cmd_file = legacy_dir / "speckit.myext.md"
|
||||
cmd_file.write_text("# ext command", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
registrar.unregister_commands(
|
||||
{"opencode": ["speckit.myext"]}, tmp_path,
|
||||
)
|
||||
|
||||
assert not cmd_file.exists()
|
||||
|
||||
def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path):
|
||||
"""Unregister removes files from legacy dir even when canonical exists."""
|
||||
# Set up both canonical and legacy dirs
|
||||
canonical_dir = tmp_path / ".opencode" / "commands"
|
||||
canonical_dir.mkdir(parents=True)
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
|
||||
# Place a command file in the legacy dir (orphaned after upgrade)
|
||||
legacy_cmd = legacy_dir / "speckit.myext.md"
|
||||
legacy_cmd.write_text("# orphaned ext command", encoding="utf-8")
|
||||
# Place the same command in the canonical dir (current)
|
||||
canonical_cmd = canonical_dir / "speckit.myext.md"
|
||||
canonical_cmd.write_text("# ext command", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
|
||||
with warnings.catch_warnings(record=True):
|
||||
warnings.simplefilter("always")
|
||||
registrar.unregister_commands(
|
||||
{"opencode": ["speckit.myext"]}, tmp_path,
|
||||
)
|
||||
|
||||
# Both files should be removed
|
||||
assert not canonical_cmd.exists(), (
|
||||
"Command file in canonical dir should be removed"
|
||||
)
|
||||
assert not legacy_cmd.exists(), (
|
||||
"Orphaned command file in legacy dir should also be removed"
|
||||
)
|
||||
|
||||
def test_canonical_dir_preferred_over_legacy(self, tmp_path):
|
||||
"""When both dirs exist, canonical .opencode/commands/ is used."""
|
||||
legacy_dir = tmp_path / ".opencode" / "command"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
canonical_dir = tmp_path / ".opencode" / "commands"
|
||||
canonical_dir.mkdir(parents=True)
|
||||
(canonical_dir / "speckit.specify.md").write_text("# cmd", encoding="utf-8")
|
||||
|
||||
# Create a source command file for the registrar
|
||||
src_dir = tmp_path / "_ext_src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "myext.md").write_text(
|
||||
"---\ndescription: test\n---\n# ext command", encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "speckit.myext", "file": "myext.md"}]
|
||||
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
results = registrar.register_commands_for_all_agents(
|
||||
commands, "test-ext", src_dir, tmp_path,
|
||||
)
|
||||
|
||||
# Should register in canonical dir, not legacy
|
||||
assert "opencode" in results
|
||||
assert (canonical_dir / "speckit.myext.md").exists()
|
||||
assert not (legacy_dir / "speckit.myext.md").exists()
|
||||
# No legacy warning when canonical dir exists
|
||||
opencode_warnings = [
|
||||
w for w in caught
|
||||
if "legacy" in str(w.message) and "opencode" in str(w.message)
|
||||
]
|
||||
assert len(opencode_warnings) == 0
|
||||
|
||||
def test_setup_writes_to_canonical_dir(self, tmp_path):
|
||||
"""New installs always write to .opencode/commands/ (plural)."""
|
||||
integration = get_integration(self.KEY)
|
||||
manifest = IntegrationManifest(self.KEY, tmp_path)
|
||||
integration.setup(tmp_path, manifest)
|
||||
|
||||
canonical = tmp_path / ".opencode" / "commands"
|
||||
legacy = tmp_path / ".opencode" / "command"
|
||||
assert canonical.is_dir()
|
||||
assert not legacy.exists()
|
||||
assert any(canonical.glob("speckit.*.md"))
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for PiIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestPiIntegration(MarkdownIntegrationTests):
|
||||
KEY = "pi"
|
||||
FOLDER = ".pi/"
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".pi/prompts"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for QodercliIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestQodercliIntegration(MarkdownIntegrationTests):
|
||||
KEY = "qodercli"
|
||||
FOLDER = ".qoder/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".qoder/commands"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for QwenIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestQwenIntegration(MarkdownIntegrationTests):
|
||||
KEY = "qwen"
|
||||
FOLDER = ".qwen/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".qwen/commands"
|
||||
@@ -0,0 +1,295 @@
|
||||
"""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", "--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"
|
||||
|
||||
# -- 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: exactly the core template set (no extension auto-install).
|
||||
assert skill_names == core_skill_names
|
||||
|
||||
# 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"
|
||||
)
|
||||
|
||||
# -- 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()
|
||||
@@ -0,0 +1,238 @@
|
||||
"""Tests for integration scaffolding commands."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.integration_scaffold import scaffold_integration
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _repo_root(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "spec-kit"
|
||||
(root / "src" / "specify_cli" / "integrations").mkdir(parents=True)
|
||||
(root / "tests" / "integrations").mkdir(parents=True)
|
||||
(root / "pyproject.toml").write_text("[project]\nname = \"specify-cli\"\n", encoding="utf-8")
|
||||
(root / "src" / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
|
||||
(root / "src" / "specify_cli" / "integrations" / "__init__.py").write_text(
|
||||
"",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
def test_integration_scaffold_creates_markdown_files(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "markdown",
|
||||
], catch_exceptions=False)
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
integration_file = root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
|
||||
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert integration_file.exists()
|
||||
assert test_file.exists()
|
||||
assert "Created integration scaffold: my-agent" in output
|
||||
assert "Register MyAgentIntegration" in output
|
||||
|
||||
content = integration_file.read_text(encoding="utf-8")
|
||||
assert "class MyAgentIntegration(MarkdownIntegration):" in content
|
||||
assert 'key = "my-agent"' in content
|
||||
assert '"folder": ".my-agent/"' in content
|
||||
assert '"extension": ".md"' in content
|
||||
assert "multi_install_safe = False" in content
|
||||
|
||||
test_content = test_file.read_text(encoding="utf-8")
|
||||
assert "from specify_cli.integrations.my_agent import MyAgentIntegration" in test_content
|
||||
assert 'assert integration.registrar_config["dir"] == ".my-agent/commands"' in test_content
|
||||
assert "assert integration.multi_install_safe is False" in test_content
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("integration_type", "base_class", "commands_subdir", "args", "extension"),
|
||||
[
|
||||
("markdown", "MarkdownIntegration", "commands", "$ARGUMENTS", ".md"),
|
||||
("toml", "TomlIntegration", "commands", "{{args}}", ".toml"),
|
||||
("yaml", "YamlIntegration", "recipes", "{{args}}", ".yaml"),
|
||||
("skills", "SkillsIntegration", "skills", "$ARGUMENTS", "/SKILL.md"),
|
||||
],
|
||||
)
|
||||
def test_scaffold_type_templates(
|
||||
tmp_path,
|
||||
integration_type,
|
||||
base_class,
|
||||
commands_subdir,
|
||||
args,
|
||||
extension,
|
||||
):
|
||||
root = _repo_root(tmp_path)
|
||||
|
||||
result = scaffold_integration(root, f"{integration_type}-agent", integration_type)
|
||||
|
||||
content = result.integration_file.read_text(encoding="utf-8")
|
||||
assert f"class {result.class_name}({base_class}):" in content
|
||||
assert f'"commands_subdir": "{commands_subdir}"' in content
|
||||
assert f'"args": "{args}"' in content
|
||||
assert f'"extension": "{extension}"' in content
|
||||
assert "multi_install_safe = False" in content
|
||||
|
||||
|
||||
def test_integration_scaffold_rejects_unknown_type_before_scaffolding(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "xml",
|
||||
])
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 2
|
||||
assert "Invalid value for '--type'" in output
|
||||
assert not (root / "src" / "specify_cli" / "integrations" / "my_agent").exists()
|
||||
|
||||
|
||||
def test_integration_scaffold_reports_filesystem_errors_cleanly(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
import specify_cli.integration_scaffold as scaffold_module
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise PermissionError("Permission denied: read-only checkout")
|
||||
|
||||
monkeypatch.setattr(scaffold_module, "scaffold_integration", boom)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "markdown",
|
||||
], catch_exceptions=False)
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "Error:" in output
|
||||
assert "Permission denied" in output
|
||||
|
||||
|
||||
def test_scaffold_refuses_invalid_key(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="lowercase kebab-case"):
|
||||
scaffold_integration(root, "Bad_Key", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_refuses_unknown_type(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported integration type 'xml'"):
|
||||
scaffold_integration(root, "my-agent", " XML ")
|
||||
|
||||
|
||||
def test_scaffold_refuses_overwrite(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
with pytest.raises(FileExistsError, match="Refusing to overwrite"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_rolls_back_partial_files_on_write_failure(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
integration_dir = root / "src" / "specify_cli" / "integrations" / "my_agent"
|
||||
integration_file = integration_dir / "__init__.py"
|
||||
test_file = root / "tests" / "integrations" / "test_integration_my_agent.py"
|
||||
original_write_text = Path.write_text
|
||||
|
||||
def fail_test_write(path, *args, **kwargs):
|
||||
if path == test_file:
|
||||
raise PermissionError("simulated test file write failure")
|
||||
return original_write_text(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "write_text", fail_test_write)
|
||||
|
||||
with pytest.raises(PermissionError, match="simulated test file write failure"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
assert not integration_file.exists()
|
||||
assert not integration_dir.exists()
|
||||
assert not test_file.exists()
|
||||
|
||||
|
||||
def test_scaffold_creates_only_leaf_integration_directory(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
original_mkdir = Path.mkdir
|
||||
mkdir_calls = []
|
||||
|
||||
def record_mkdir(path, *args, **kwargs):
|
||||
mkdir_calls.append((path, args, kwargs))
|
||||
return original_mkdir(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", record_mkdir)
|
||||
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
assert any(
|
||||
path == root / "src" / "specify_cli" / "integrations" / "my_agent"
|
||||
for path, _args, _kwargs in mkdir_calls
|
||||
)
|
||||
assert all(not kwargs.get("parents", False) for _path, _args, kwargs in mkdir_calls)
|
||||
|
||||
|
||||
def test_scaffold_requires_repo_root(tmp_path):
|
||||
with pytest.raises(ValueError, match="Spec Kit repository root"):
|
||||
scaffold_integration(tmp_path, "my-agent", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_requires_integration_registry_file(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
(root / "src" / "specify_cli" / "integrations" / "__init__.py").unlink()
|
||||
|
||||
with pytest.raises(ValueError, match="Spec Kit repository root"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
|
||||
def test_scaffold_refuses_symlinked_target_directory(tmp_path):
|
||||
root = _repo_root(tmp_path)
|
||||
# `outside` carries its own __init__.py so the repo-root heuristic still
|
||||
# passes through the symlink, isolating the symlink guard under test.
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "__init__.py").write_text("", encoding="utf-8")
|
||||
integrations = root / "src" / "specify_cli" / "integrations"
|
||||
(integrations / "__init__.py").unlink()
|
||||
integrations.rmdir()
|
||||
try:
|
||||
integrations.symlink_to(outside, target_is_directory=True)
|
||||
except OSError as exc:
|
||||
pytest.skip(f"symlinks unavailable: {exc}")
|
||||
|
||||
with pytest.raises(ValueError, match="symlinked path"):
|
||||
scaffold_integration(root, "my-agent", "markdown")
|
||||
|
||||
assert not (outside / "my_agent").exists()
|
||||
|
||||
|
||||
def test_integration_scaffold_accepts_uppercase_type(tmp_path, monkeypatch):
|
||||
root = _repo_root(tmp_path)
|
||||
monkeypatch.chdir(root)
|
||||
|
||||
result = runner.invoke(app, [
|
||||
"integration", "scaffold", "my-agent",
|
||||
"--type", "YAML",
|
||||
], catch_exceptions=False)
|
||||
|
||||
assert result.exit_code == 0, strip_ansi(result.output)
|
||||
content = (
|
||||
root / "src" / "specify_cli" / "integrations" / "my_agent" / "__init__.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "class MyAgentIntegration(YamlIntegration):" in content
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for ShaiIntegration."""
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestShaiIntegration(MarkdownIntegrationTests):
|
||||
KEY = "shai"
|
||||
FOLDER = ".shai/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".shai/commands"
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Tests for integration state normalization helpers."""
|
||||
|
||||
import json
|
||||
|
||||
from specify_cli.integration_state import (
|
||||
INTEGRATION_JSON,
|
||||
default_integration_key,
|
||||
integration_setting,
|
||||
normalize_integration_state,
|
||||
write_integration_json,
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_integration_state_strips_default_key_without_duplicates():
|
||||
state = normalize_integration_state(
|
||||
{
|
||||
"default_integration": " claude ",
|
||||
"integration": " claude ",
|
||||
"installed_integrations": ["claude"],
|
||||
}
|
||||
)
|
||||
|
||||
assert state["integration"] == "claude"
|
||||
assert state["default_integration"] == "claude"
|
||||
assert state["installed_integrations"] == ["claude"]
|
||||
|
||||
|
||||
def test_normalize_integration_state_strips_legacy_key_fallback():
|
||||
state = normalize_integration_state(
|
||||
{
|
||||
"integration": " codex ",
|
||||
"installed_integrations": [],
|
||||
}
|
||||
)
|
||||
|
||||
assert state["integration"] == "codex"
|
||||
assert state["default_integration"] == "codex"
|
||||
assert state["installed_integrations"] == ["codex"]
|
||||
|
||||
|
||||
def test_normalize_integration_state_preserves_newer_schema():
|
||||
state = normalize_integration_state(
|
||||
{
|
||||
"integration_state_schema": 99,
|
||||
"integration": "claude",
|
||||
"installed_integrations": ["claude"],
|
||||
"future_field": {"keep": True},
|
||||
}
|
||||
)
|
||||
|
||||
assert state["integration_state_schema"] == 99
|
||||
assert state["future_field"] == {"keep": True}
|
||||
|
||||
|
||||
def test_default_integration_key_strips_raw_state_values():
|
||||
assert default_integration_key({"default_integration": " claude "}) == "claude"
|
||||
assert default_integration_key({"integration": " codex "}) == "codex"
|
||||
|
||||
|
||||
def test_integration_settings_strip_invoke_separator():
|
||||
setting = integration_setting(
|
||||
{
|
||||
"integration_settings": {
|
||||
"claude": {
|
||||
"invoke_separator": " - ",
|
||||
}
|
||||
}
|
||||
},
|
||||
"claude",
|
||||
)
|
||||
|
||||
assert setting["invoke_separator"] == "-"
|
||||
|
||||
|
||||
def test_write_integration_json_strips_integration_key(tmp_path):
|
||||
write_integration_json(
|
||||
tmp_path,
|
||||
version="1.2.3",
|
||||
integration_key=" claude ",
|
||||
installed_integrations=["claude"],
|
||||
)
|
||||
|
||||
state = json.loads((tmp_path / INTEGRATION_JSON).read_text(encoding="utf-8"))
|
||||
assert state["integration"] == "claude"
|
||||
assert state["default_integration"] == "claude"
|
||||
assert state["installed_integrations"] == ["claude"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
"""Tests for TabnineIntegration."""
|
||||
|
||||
from .test_integration_base_toml import TomlIntegrationTests
|
||||
|
||||
|
||||
class TestTabnineIntegration(TomlIntegrationTests):
|
||||
KEY = "tabnine"
|
||||
FOLDER = ".tabnine/agent/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".tabnine/agent/commands"
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Tests for TraeIntegration."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestTraeIntegration(SkillsIntegrationTests):
|
||||
KEY = "trae"
|
||||
FOLDER = ".trae/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".trae/skills"
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for VibeIntegration."""
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestVibeIntegration(SkillsIntegrationTests):
|
||||
KEY = "vibe"
|
||||
FOLDER = ".vibe/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".vibe/skills"
|
||||
|
||||
|
||||
class TestVibeUserInvocable:
|
||||
def test_all_skills_have_user_invocable(self, tmp_path):
|
||||
i = get_integration("vibe")
|
||||
m = IntegrationManifest("vibe", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert skill_files
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert content.startswith("---"), (
|
||||
f"{f.parent.name}/SKILL.md is missing the opening frontmatter delimiter '---'"
|
||||
)
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3, (
|
||||
f"{f.parent.name}/SKILL.md has malformed frontmatter; expected a '--- ... ---' block"
|
||||
)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed.get("user-invocable") is True, (
|
||||
f"{f.parent.name}/SKILL.md is missing user-invocable: true in frontmatter"
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for ZcodeIntegration — skills-based integration (Z.AI)."""
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestZcodeIntegration(SkillsIntegrationTests):
|
||||
KEY = "zcode"
|
||||
FOLDER = ".zcode/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".zcode/skills"
|
||||
|
||||
|
||||
class TestZcodeInvocation:
|
||||
"""ZCode renders $speckit-* chat invocations (like Codex)."""
|
||||
|
||||
def test_next_steps_show_dollar_skill_invocation(self, tmp_path):
|
||||
"""ZCode next-steps guidance should display $speckit-* usage."""
|
||||
import os
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "zcode-next-steps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--integration", "zcode",
|
||||
"--ignore-agent-tools", "--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "$speckit-constitution" in result.output
|
||||
assert "/speckit.constitution" not in result.output
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for ZedIntegration."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestZedIntegration(SkillsIntegrationTests):
|
||||
KEY = "zed"
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Not applicable to Zed — Zed is always skills-based with no --skills flag."""
|
||||
pytest.skip("Zed is always skills-based and does not expose a --skills option")
|
||||
|
||||
def test_options_do_not_include_skills_flag(self):
|
||||
"""Zed is always skills-based; no --skills option is exposed."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i is not None
|
||||
opts = i.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 0, (
|
||||
"Zed is always skills-based and should not expose a --skills option"
|
||||
)
|
||||
|
||||
def test_requires_cli_is_false(self):
|
||||
"""Zed is IDE-based; requires_cli must remain False."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i is not None
|
||||
assert i.config is not None
|
||||
assert i.config["requires_cli"] is False
|
||||
|
||||
|
||||
class TestZedHookInvocations:
|
||||
"""Zed hook messages should reference slash-invokable skills."""
|
||||
|
||||
def test_hooks_render_skill_invocation(self, tmp_path):
|
||||
"""Zed is always skills-based: renders /speckit-plan even with ai_skills=False."""
|
||||
from specify_cli.extensions import HookExecutor
|
||||
|
||||
project = tmp_path / "zed-hooks"
|
||||
project.mkdir()
|
||||
init_options = project / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "zed", "ai_skills": False}))
|
||||
|
||||
hook_executor = HookExecutor(project)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_plan",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.plan",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
|
||||
|
||||
def test_init_persists_ai_skills_for_zed(self, tmp_path, monkeypatch):
|
||||
"""specify init --integration zed must persist ai_skills: true,
|
||||
so HookExecutor renders slash-skill invocations without manual
|
||||
init-options manipulation."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.extensions import HookExecutor
|
||||
|
||||
project = tmp_path / "zed-init-test"
|
||||
project.mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"zed",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
|
||||
opts_path = project / ".specify" / "init-options.json"
|
||||
assert opts_path.exists()
|
||||
opts = json.loads(opts_path.read_text(encoding="utf-8"))
|
||||
assert opts.get("ai") == "zed"
|
||||
assert opts.get("ai_skills") is True, (
|
||||
f"init must persist ai_skills=true for Zed, got: {opts.get('ai_skills')}"
|
||||
)
|
||||
|
||||
hook_executor = HookExecutor(project)
|
||||
message = hook_executor.format_hook_message(
|
||||
"before_plan",
|
||||
[
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.plan",
|
||||
"optional": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
assert "Executing: `/speckit-plan`" in message, (
|
||||
"Hook rendering must produce /speckit-plan for Zed without hint injection"
|
||||
)
|
||||
assert "EXECUTE_COMMAND_INVOCATION: /speckit-plan" in message
|
||||
|
||||
|
||||
class TestSlashSkillsSets:
|
||||
"""Parameterized coverage for ALWAYS_SLASH_AGENTS / CONDITIONAL_SLASH_AGENTS."""
|
||||
|
||||
@staticmethod
|
||||
def _render_invocation(project_path, ai: str, ai_skills: bool) -> str:
|
||||
"""Return the rendered invocation for ``speckit.plan`` via HookExecutor."""
|
||||
from specify_cli.extensions import HookExecutor
|
||||
|
||||
init_options = project_path / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": ai, "ai_skills": ai_skills}))
|
||||
hook_executor = HookExecutor(project_path)
|
||||
result = hook_executor.execute_hook(
|
||||
{"extension": "test-ext", "command": "speckit.plan", "optional": False}
|
||||
)
|
||||
return result.get("invocation", "")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ai", "ai_skills", "expected"),
|
||||
[
|
||||
# ALWAYS_SLASH_AGENTS — unconditional on ai_skills
|
||||
("devin", True, "/speckit-plan"),
|
||||
("devin", False, "/speckit-plan"),
|
||||
("trae", True, "/speckit-plan"),
|
||||
("trae", False, "/speckit-plan"),
|
||||
("zed", True, "/speckit-plan"),
|
||||
("zed", False, "/speckit-plan"),
|
||||
# CONDITIONAL_SLASH_AGENTS — only when ai_skills is enabled
|
||||
("agy", True, "/speckit-plan"),
|
||||
("agy", False, "/speckit.plan"),
|
||||
("claude", True, "/speckit-plan"),
|
||||
("claude", False, "/speckit.plan"),
|
||||
("copilot", True, "/speckit-plan"),
|
||||
("copilot", False, "/speckit.plan"),
|
||||
("cursor-agent", True, "/speckit-plan"),
|
||||
("cursor-agent", False, "/speckit.plan"),
|
||||
],
|
||||
)
|
||||
def test_hook_invocation_format(self, tmp_path, ai, ai_skills, expected):
|
||||
result = self._render_invocation(tmp_path, ai, ai_skills)
|
||||
assert result == expected, (
|
||||
f"{ai} (ai_skills={ai_skills}): expected {expected!r}, got {result!r}"
|
||||
)
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Tests for IntegrationManifest — record, hash, save, load, uninstall, modified detection."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.manifest import IntegrationManifest, _sha256
|
||||
|
||||
|
||||
class TestManifestRecordFile:
|
||||
def test_record_file_writes_and_hashes(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
content = "hello world"
|
||||
abs_path = m.record_file("a/b.txt", content)
|
||||
assert abs_path == tmp_path / "a" / "b.txt"
|
||||
assert abs_path.read_text(encoding="utf-8") == content
|
||||
expected_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
assert m.files["a/b.txt"] == expected_hash
|
||||
|
||||
def test_record_file_bytes(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
data = b"\x00\x01\x02"
|
||||
abs_path = m.record_file("bin.dat", data)
|
||||
assert abs_path.read_bytes() == data
|
||||
assert m.files["bin.dat"] == hashlib.sha256(data).hexdigest()
|
||||
|
||||
def test_record_existing(self, tmp_path):
|
||||
f = tmp_path / "existing.txt"
|
||||
f.write_text("content", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("existing.txt")
|
||||
assert m.files["existing.txt"] == _sha256(f)
|
||||
|
||||
|
||||
class TestManifestRecordExistingErrors:
|
||||
"""Error-case coverage for ``record_existing`` symlink + non-file guards.
|
||||
|
||||
Added in #2483 — Copilot review flagged these as un-tested regressions
|
||||
after the ``is_symlink``/``is_file`` guards were introduced.
|
||||
"""
|
||||
|
||||
def test_rejects_symlink_target(self, tmp_path):
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("target content", encoding="utf-8")
|
||||
link = tmp_path / "link.txt"
|
||||
link.symlink_to(target)
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
m.record_existing("link.txt")
|
||||
|
||||
def test_rejects_dangling_symlink(self, tmp_path):
|
||||
# A symlink pointing nowhere should still be rejected before the
|
||||
# ``is_file()`` check (which would itself be False on a dangler).
|
||||
link = tmp_path / "dangler.txt"
|
||||
link.symlink_to(tmp_path / "no-such-target.txt")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
m.record_existing("dangler.txt")
|
||||
|
||||
def test_rejects_directory_path(self, tmp_path):
|
||||
(tmp_path / "a_dir").mkdir()
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="not a regular file"):
|
||||
m.record_existing("a_dir")
|
||||
|
||||
def test_rejects_missing_path(self, tmp_path):
|
||||
# ``is_file()`` is False for non-existent paths too; the same error
|
||||
# surface keeps callers from having to distinguish "missing" from
|
||||
# "wrong kind" — both mean "cannot hash this".
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="not a regular file"):
|
||||
m.record_existing("never-existed.txt")
|
||||
|
||||
def test_lexical_prevalidation_for_absolute_path(self, tmp_path):
|
||||
# ``record_existing`` must reject absolute paths via the lexical
|
||||
# pre-check, NOT via the filesystem-touching ``is_symlink()`` call.
|
||||
# Verified by passing an absolute path that points to a directory
|
||||
# outside the project root — the canonical "Absolute paths" error
|
||||
# must surface before any stat on the absolute path.
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
|
||||
with pytest.raises(ValueError, match="Absolute paths"):
|
||||
m.record_existing(abs_path)
|
||||
|
||||
|
||||
class TestManifestPathTraversal:
|
||||
def test_record_file_rejects_parent_traversal(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
m.record_file("../escape.txt", "bad")
|
||||
|
||||
def test_record_file_rejects_absolute_path(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
abs_path = "C:\\tmp\\escape.txt" if sys.platform == "win32" else "/tmp/escape.txt"
|
||||
with pytest.raises(ValueError, match="Absolute paths"):
|
||||
m.record_file(abs_path, "bad")
|
||||
|
||||
def test_record_existing_rejects_parent_traversal(self, tmp_path):
|
||||
escape = tmp_path.parent / "escape.txt"
|
||||
escape.write_text("evil", encoding="utf-8")
|
||||
try:
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="outside"):
|
||||
m.record_existing("../escape.txt")
|
||||
finally:
|
||||
escape.unlink(missing_ok=True)
|
||||
|
||||
def test_uninstall_skips_traversal_paths(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("safe.txt", "good")
|
||||
m._files["../outside.txt"] = "fakehash"
|
||||
m.save()
|
||||
removed, skipped = m.uninstall()
|
||||
assert len(removed) == 1
|
||||
assert removed[0].name == "safe.txt"
|
||||
|
||||
def test_remove_drops_entry_and_is_noop_second_time(self, tmp_path):
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt")
|
||||
assert "f.txt" in m.files
|
||||
assert m.remove("f.txt") is True
|
||||
assert "f.txt" not in m.files
|
||||
assert m.remove("f.txt") is False # already gone → no-op
|
||||
|
||||
def test_remove_rejects_absolute_path(self, tmp_path):
|
||||
# Matches record_existing/is_recovered: an absolute key can never be a
|
||||
# canonical manifest key, so remove() rejects it lexically and leaves
|
||||
# the tracked entry untouched.
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt")
|
||||
import sys
|
||||
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
|
||||
assert m.remove(abs_input) is False
|
||||
assert "f.txt" in m.files
|
||||
|
||||
def test_remove_rejects_parent_traversal(self, tmp_path):
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt")
|
||||
assert m.remove("../f.txt") is False
|
||||
assert "f.txt" in m.files
|
||||
|
||||
|
||||
class TestManifestCheckModified:
|
||||
def test_unmodified_file(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
assert m.check_modified() == []
|
||||
|
||||
def test_modified_file(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
(tmp_path / "f.txt").write_text("changed", encoding="utf-8")
|
||||
assert m.check_modified() == ["f.txt"]
|
||||
|
||||
def test_deleted_file_not_reported(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
(tmp_path / "f.txt").unlink()
|
||||
assert m.check_modified() == []
|
||||
|
||||
def test_symlink_treated_as_modified(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("target", encoding="utf-8")
|
||||
(tmp_path / "f.txt").unlink()
|
||||
(tmp_path / "f.txt").symlink_to(target)
|
||||
assert m.check_modified() == ["f.txt"]
|
||||
|
||||
|
||||
class TestManifestUninstall:
|
||||
def test_removes_unmodified(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("d/f.txt", "content")
|
||||
m.save()
|
||||
removed, skipped = m.uninstall()
|
||||
assert len(removed) == 1
|
||||
assert not (tmp_path / "d" / "f.txt").exists()
|
||||
assert not (tmp_path / "d").exists()
|
||||
assert skipped == []
|
||||
|
||||
def test_skips_modified(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
m.save()
|
||||
(tmp_path / "f.txt").write_text("modified", encoding="utf-8")
|
||||
removed, skipped = m.uninstall()
|
||||
assert removed == []
|
||||
assert len(skipped) == 1
|
||||
assert (tmp_path / "f.txt").exists()
|
||||
|
||||
def test_force_removes_modified(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
m.save()
|
||||
(tmp_path / "f.txt").write_text("modified", encoding="utf-8")
|
||||
removed, skipped = m.uninstall(force=True)
|
||||
assert len(removed) == 1
|
||||
assert skipped == []
|
||||
|
||||
def test_already_deleted_file(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "content")
|
||||
m.save()
|
||||
(tmp_path / "f.txt").unlink()
|
||||
removed, skipped = m.uninstall()
|
||||
assert removed == []
|
||||
assert skipped == []
|
||||
|
||||
def test_removes_manifest_file(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path, version="1.0")
|
||||
m.record_file("f.txt", "content")
|
||||
m.save()
|
||||
assert m.manifest_path.exists()
|
||||
m.uninstall()
|
||||
assert not m.manifest_path.exists()
|
||||
|
||||
def test_cleans_empty_parent_dirs(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("a/b/c/f.txt", "content")
|
||||
m.save()
|
||||
m.uninstall()
|
||||
assert not (tmp_path / "a").exists()
|
||||
|
||||
def test_preserves_nonempty_parent_dirs(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("a/b/tracked.txt", "content")
|
||||
(tmp_path / "a" / "b" / "other.txt").write_text("keep", encoding="utf-8")
|
||||
m.save()
|
||||
m.uninstall()
|
||||
assert not (tmp_path / "a" / "b" / "tracked.txt").exists()
|
||||
assert (tmp_path / "a" / "b" / "other.txt").exists()
|
||||
|
||||
def test_symlink_skipped_without_force(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
m.save()
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("target", encoding="utf-8")
|
||||
(tmp_path / "f.txt").unlink()
|
||||
(tmp_path / "f.txt").symlink_to(target)
|
||||
removed, skipped = m.uninstall()
|
||||
assert removed == []
|
||||
assert len(skipped) == 1
|
||||
|
||||
def test_symlink_removed_with_force(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "original")
|
||||
m.save()
|
||||
target = tmp_path / "target.txt"
|
||||
target.write_text("target", encoding="utf-8")
|
||||
(tmp_path / "f.txt").unlink()
|
||||
(tmp_path / "f.txt").symlink_to(target)
|
||||
removed, skipped = m.uninstall(force=True)
|
||||
assert len(removed) == 1
|
||||
assert target.exists()
|
||||
|
||||
|
||||
class TestManifestPersistence:
|
||||
def test_save_and_load_roundtrip(self, tmp_path):
|
||||
m = IntegrationManifest("myagent", tmp_path, version="2.0.1")
|
||||
m.record_file("dir/file.md", "# Hello")
|
||||
m.save()
|
||||
loaded = IntegrationManifest.load("myagent", tmp_path)
|
||||
assert loaded.key == "myagent"
|
||||
assert loaded.version == "2.0.1"
|
||||
assert loaded.files == m.files
|
||||
|
||||
def test_manifest_path(self, tmp_path):
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
assert m.manifest_path == tmp_path / ".specify" / "integrations" / "copilot.manifest.json"
|
||||
|
||||
def test_load_missing_raises(self, tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
IntegrationManifest.load("nonexistent", tmp_path)
|
||||
|
||||
def test_save_creates_directories(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "content")
|
||||
path = m.save()
|
||||
assert path.exists()
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert data["integration"] == "test"
|
||||
|
||||
def test_save_preserves_installed_at(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "content")
|
||||
m.save()
|
||||
first_ts = m._installed_at
|
||||
m.save()
|
||||
assert m._installed_at == first_ts
|
||||
|
||||
|
||||
class TestManifestLoadValidation:
|
||||
def test_load_non_dict_raises(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text('"just a string"', encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_bad_files_type_raises(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({"files": ["not", "a", "dict"]}), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="mapping"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_bad_files_values_raises(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({"files": {"a.txt": 123}}), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="mapping"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_invalid_json_raises(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="invalid JSON"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
|
||||
# Finding B (Round-9): a recovered_files entry referencing a path
|
||||
# not present in files indicates an internally-inconsistent manifest
|
||||
# (e.g. external edit). load() filters those entries silently so the
|
||||
# manifest self-heals on next save(); is_recovered then returns the
|
||||
# truthful False for the orphan.
|
||||
path = tmp_path / ".specify" / "integrations" / "test.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({
|
||||
"integration": "test",
|
||||
"files": {"kept.txt": "abc123"},
|
||||
"recovered_files": ["kept.txt", "orphan.txt"],
|
||||
}), encoding="utf-8")
|
||||
m = IntegrationManifest.load("test", tmp_path)
|
||||
assert m.recovered_files == {"kept.txt"}
|
||||
assert m.is_recovered("kept.txt") is True
|
||||
assert m.is_recovered("orphan.txt") is False
|
||||
|
||||
|
||||
class TestManifestRecoveredFiles:
|
||||
"""Coverage for the ``recovered_files`` channel added in #2483.
|
||||
|
||||
When ``shared_infra`` skips an existing file (because the user already has
|
||||
it on disk) it now records the file with ``recovered=True``. The path
|
||||
appears in ``manifest.recovered_files`` and ``is_recovered(path)`` returns
|
||||
True. ``refresh_managed`` (out of scope for this PR) consults this list
|
||||
before treating the recorded hash as a managed baseline, defending against
|
||||
silent overwrite of user customizations after manifest loss.
|
||||
"""
|
||||
|
||||
def test_record_existing_default_is_not_recovered(self, tmp_path):
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt")
|
||||
assert m.is_recovered("f.txt") is False
|
||||
assert m.recovered_files == set()
|
||||
|
||||
def test_record_existing_with_recovered_flag(self, tmp_path):
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt", recovered=True)
|
||||
assert m.is_recovered("f.txt") is True
|
||||
assert m.recovered_files == {"f.txt"}
|
||||
# File still hashed normally so check_modified/uninstall keep working
|
||||
assert m.files["f.txt"] == _sha256(tmp_path / "f.txt")
|
||||
|
||||
def test_recovered_files_round_trips_through_save_load(self, tmp_path):
|
||||
(tmp_path / "a.txt").write_text("aaa", encoding="utf-8")
|
||||
(tmp_path / "b.txt").write_text("bbb", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path, version="9.9")
|
||||
m.record_existing("a.txt", recovered=True)
|
||||
m.record_existing("b.txt") # not recovered
|
||||
m.save()
|
||||
loaded = IntegrationManifest.load("test", tmp_path)
|
||||
assert loaded.is_recovered("a.txt") is True
|
||||
assert loaded.is_recovered("b.txt") is False
|
||||
assert loaded.recovered_files == {"a.txt"}
|
||||
|
||||
def test_save_omits_empty_recovered_files(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("f.txt", "x")
|
||||
path = m.save()
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert "recovered_files" not in data
|
||||
|
||||
def test_load_rejects_non_list_recovered_files(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(
|
||||
json.dumps({"files": {}, "recovered_files": "not-a-list"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValueError, match="recovered_files"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_is_recovered_absolute_path_returns_false(self, tmp_path):
|
||||
# Copilot round-5 finding: passing an absolute path silently returned
|
||||
# False because the stored keys are relative POSIX strings. Round-7
|
||||
# made this explicit: ``is_recovered`` now rejects absolute paths
|
||||
# up front via a lexical ``rel.is_absolute()`` guard and returns
|
||||
# False without calling ``_validate_rel_path`` at all — matching
|
||||
# ``record_existing``'s canonical-key guard so the two methods
|
||||
# agree on which inputs can ever be stored keys.
|
||||
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("f.txt", recovered=True)
|
||||
import sys
|
||||
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
|
||||
assert m.is_recovered(abs_input) is False
|
||||
|
||||
def test_is_recovered_escaping_path_returns_false(self, tmp_path):
|
||||
# A relative path containing ``..`` segments cannot be a stored key:
|
||||
# Round-7 added the same lexical ``".." in rel.parts`` guard to
|
||||
# ``is_recovered`` that ``record_existing`` already enforces, so the
|
||||
# method returns False immediately without reaching
|
||||
# ``_validate_rel_path``. The try/except around ``_validate_rel_path``
|
||||
# remains as defense-in-depth for paths that pass the lexical guard
|
||||
# but still resolve outside the project root via a symlinked
|
||||
# ancestor.
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
# Don't record anything — the path is impossible to record anyway.
|
||||
assert m.is_recovered("../escape.txt") is False
|
||||
|
||||
def test_record_existing_clears_recovered_when_false(self, tmp_path):
|
||||
# Finding A: re-recording the same path with recovered=False must
|
||||
# drop the prior recovered marker (transition to managed baseline).
|
||||
f = tmp_path / "x.txt"
|
||||
f.write_text("v1", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("x.txt", recovered=True)
|
||||
assert m.is_recovered("x.txt") is True
|
||||
m.record_existing("x.txt", recovered=False)
|
||||
assert m.is_recovered("x.txt") is False
|
||||
|
||||
def test_record_file_clears_recovered(self, tmp_path):
|
||||
# Finding A: record_file writes produced content; the path can no
|
||||
# longer be considered "merely observed" once we wrote bytes.
|
||||
(tmp_path / "y.txt").write_text("observed", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("y.txt", recovered=True)
|
||||
assert m.is_recovered("y.txt") is True
|
||||
m.record_file("y.txt", "produced")
|
||||
assert m.is_recovered("y.txt") is False
|
||||
|
||||
def test_is_recovered_rejects_dotdot_segment(self, tmp_path):
|
||||
# Finding B: record_existing rejects ``..`` segments via the lexical
|
||||
# pre-check; is_recovered must match that behavior and return False
|
||||
# without raising, mirroring the canonicalization guard.
|
||||
(tmp_path / "z.txt").write_text("v1", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_existing("z.txt", recovered=True)
|
||||
# Same file via dotdot-normalizing path — must be False, not raise.
|
||||
assert m.is_recovered("subdir/../z.txt") is False
|
||||
|
||||
|
||||
class TestRecordExistingNewGuards:
|
||||
"""Coverage for the two new guards added by Copilot's 2026-05-18 review."""
|
||||
|
||||
def test_rejects_symlinked_ancestor(self, tmp_path):
|
||||
real_dir = tmp_path / "real_dir"
|
||||
real_dir.mkdir()
|
||||
(real_dir / "file.txt").write_text("payload", encoding="utf-8")
|
||||
(tmp_path / "linked_dir").symlink_to(real_dir, target_is_directory=True)
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match="symlinked"):
|
||||
m.record_existing("linked_dir/file.txt")
|
||||
|
||||
def test_rejects_inside_root_dotdot_with_explicit_message(self, tmp_path):
|
||||
# ``dir/../file.txt`` normalizes inside root, so the old "escapes
|
||||
# project root" message was misleading. The new message names the
|
||||
# actual reason: canonicalization.
|
||||
(tmp_path / "dir").mkdir()
|
||||
(tmp_path / "file.txt").write_text("x", encoding="utf-8")
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
with pytest.raises(ValueError, match=r"canonical|'\.\.' segments"):
|
||||
m.record_existing("dir/../file.txt")
|
||||
|
||||
|
||||
class TestManifestUnreadableFile:
|
||||
"""A managed file that is unreadable (e.g. PermissionError) must not crash
|
||||
check_modified()/uninstall() — the CLI handlers surfaced a raw traceback."""
|
||||
|
||||
def _mk(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("sub/f.md", "content")
|
||||
return m
|
||||
|
||||
def test_check_modified_treats_unreadable_as_modified(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
# Before the fix this raised PermissionError.
|
||||
assert m.check_modified() == ["sub/f.md"]
|
||||
|
||||
def test_uninstall_preserves_unreadable_file(self, tmp_path, monkeypatch):
|
||||
m = self._mk(tmp_path)
|
||||
|
||||
def raise_perm(_path):
|
||||
raise PermissionError("unreadable")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.manifest._sha256", raise_perm
|
||||
)
|
||||
removed, skipped = m.uninstall(force=False)
|
||||
# Can't verify ownership => preserve, don't crash and don't delete.
|
||||
assert removed == []
|
||||
assert (tmp_path / "sub" / "f.md") in skipped
|
||||
assert (tmp_path / "sub" / "f.md").exists()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.integrations import (
|
||||
INTEGRATION_REGISTRY,
|
||||
_register,
|
||||
get_integration,
|
||||
)
|
||||
from specify_cli.integrations.base import MarkdownIntegration
|
||||
from .conftest import StubIntegration
|
||||
|
||||
|
||||
# Every integration key that must be registered (Stage 2 + Stage 3 + Stage 4 + Stage 5).
|
||||
ALL_INTEGRATION_KEYS = [
|
||||
"copilot",
|
||||
# Stage 3 — standard markdown integrations
|
||||
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
|
||||
"rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
|
||||
"pi", "kiro-cli", "vibe", "cursor-agent", "firebender",
|
||||
# Stage 4 — TOML integrations
|
||||
"gemini", "tabnine",
|
||||
# Stage 5 — skills, generic & option-driven integrations
|
||||
"codex", "kimi", "agy", "zed", "generic",
|
||||
]
|
||||
|
||||
|
||||
def _multi_install_safe_keys() -> list[str]:
|
||||
return sorted(
|
||||
key
|
||||
for key, integration in INTEGRATION_REGISTRY.items()
|
||||
if integration.multi_install_safe
|
||||
)
|
||||
|
||||
|
||||
def _multi_install_safe_pairs() -> list[tuple[str, str]]:
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
return [
|
||||
(safe_keys[left], safe_keys[right])
|
||||
for left in range(len(safe_keys))
|
||||
for right in range(left + 1, len(safe_keys))
|
||||
]
|
||||
|
||||
|
||||
def _multi_install_safe_orders() -> list[list[str]]:
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
if len(safe_keys) < 2:
|
||||
return [safe_keys]
|
||||
return [safe_keys[index:] + safe_keys[:index] for index in range(len(safe_keys))]
|
||||
|
||||
|
||||
def _multi_install_safe_order_id(ordered_keys: list[str]) -> str:
|
||||
if not ordered_keys:
|
||||
return "no-safe-integrations"
|
||||
return f"init-{ordered_keys[0]}"
|
||||
|
||||
|
||||
def _posix_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return PurePosixPath(value).as_posix()
|
||||
|
||||
|
||||
def _integration_root_dir(key: str) -> str | None:
|
||||
integration = INTEGRATION_REGISTRY[key]
|
||||
cfg = integration.config if isinstance(integration.config, dict) else {}
|
||||
return _posix_path(cfg.get("folder"))
|
||||
|
||||
|
||||
def _integration_commands_dir(key: str) -> str | None:
|
||||
integration = INTEGRATION_REGISTRY[key]
|
||||
cfg = integration.config if isinstance(integration.config, dict) else {}
|
||||
folder = cfg.get("folder")
|
||||
if not folder:
|
||||
return None
|
||||
subdir = cfg.get("commands_subdir", "commands")
|
||||
return (PurePosixPath(folder) / subdir).as_posix()
|
||||
|
||||
|
||||
def _paths_overlap(first: str | None, second: str | None) -> bool:
|
||||
if not first or not second:
|
||||
return False
|
||||
left = PurePosixPath(first)
|
||||
right = PurePosixPath(second)
|
||||
try:
|
||||
left.relative_to(right)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
right.relative_to(left)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
def test_registry_is_dict(self):
|
||||
assert isinstance(INTEGRATION_REGISTRY, dict)
|
||||
|
||||
def test_register_and_get(self):
|
||||
stub = StubIntegration()
|
||||
_register(stub)
|
||||
try:
|
||||
assert get_integration("stub") is stub
|
||||
finally:
|
||||
INTEGRATION_REGISTRY.pop("stub", None)
|
||||
|
||||
def test_get_missing_returns_none(self):
|
||||
assert get_integration("nonexistent-xyz") is None
|
||||
|
||||
def test_register_empty_key_raises(self):
|
||||
class EmptyKey(MarkdownIntegration):
|
||||
key = ""
|
||||
with pytest.raises(ValueError, match="empty key"):
|
||||
_register(EmptyKey())
|
||||
|
||||
def test_register_duplicate_raises(self):
|
||||
stub = StubIntegration()
|
||||
_register(stub)
|
||||
try:
|
||||
with pytest.raises(KeyError, match="already registered"):
|
||||
_register(StubIntegration())
|
||||
finally:
|
||||
INTEGRATION_REGISTRY.pop("stub", None)
|
||||
|
||||
|
||||
class TestRegistryCompleteness:
|
||||
"""Every expected integration must be registered."""
|
||||
|
||||
@pytest.mark.parametrize("key", ALL_INTEGRATION_KEYS)
|
||||
def test_key_registered(self, key):
|
||||
assert key in INTEGRATION_REGISTRY, f"{key} missing from registry"
|
||||
|
||||
|
||||
class TestRegistrarKeyAlignment:
|
||||
"""Every integration key must have a matching AGENT_CONFIGS entry.
|
||||
|
||||
``generic`` is excluded because it has no fixed directory — its
|
||||
output path comes from ``--commands-dir`` at runtime.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[k for k in ALL_INTEGRATION_KEYS if k != "generic"],
|
||||
)
|
||||
def test_integration_key_in_registrar(self, key):
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
assert key in CommandRegistrar.AGENT_CONFIGS, (
|
||||
f"Integration '{key}' is registered but has no AGENT_CONFIGS entry"
|
||||
)
|
||||
|
||||
def test_no_stale_cursor_shorthand(self):
|
||||
"""The old 'cursor' shorthand must not appear in AGENT_CONFIGS."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
assert "cursor" not in CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
|
||||
class TestMultiInstallSafeContracts:
|
||||
"""Declared safe integrations must stay isolated from each other."""
|
||||
|
||||
def test_safe_install_orders_rotate_each_integration_through_init(self):
|
||||
safe_keys = _multi_install_safe_keys()
|
||||
orders = _multi_install_safe_orders()
|
||||
|
||||
assert len(safe_keys) >= 2
|
||||
assert [order[0] for order in orders] == safe_keys
|
||||
assert len({tuple(order) for order in orders}) == len(safe_keys)
|
||||
assert all(sorted(order) == safe_keys for order in orders)
|
||||
|
||||
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||
assert _integration_root_dir(key), (
|
||||
f"{key} is declared multi-install safe but has no static root directory"
|
||||
)
|
||||
assert _integration_commands_dir(key), (
|
||||
f"{key} is declared multi-install safe but has no static commands directory"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_distinct_agent_roots(self, first, second):
|
||||
assert not _paths_overlap(_integration_root_dir(first), _integration_root_dir(second)), (
|
||||
f"{first} and {second} are declared multi-install safe but have "
|
||||
f"overlapping agent roots {_integration_root_dir(first)!r} and "
|
||||
f"{_integration_root_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_distinct_command_dirs(self, first, second):
|
||||
assert not _paths_overlap(_integration_commands_dir(first), _integration_commands_dir(second)), (
|
||||
f"{first} and {second} are declared multi-install safe but have "
|
||||
f"overlapping command directories {_integration_commands_dir(first)!r} and "
|
||||
f"{_integration_commands_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ordered_keys",
|
||||
_multi_install_safe_orders(),
|
||||
ids=_multi_install_safe_order_id,
|
||||
)
|
||||
def test_safe_integrations_have_disjoint_manifests(
|
||||
self,
|
||||
tmp_path,
|
||||
ordered_keys,
|
||||
):
|
||||
# The pairwise disjointness contract is only meaningful with at least
|
||||
# two safe integrations. Guard so a shrunken registry fails loudly here
|
||||
# rather than passing vacuously (or tripping over ordered_keys[0] below).
|
||||
assert len(ordered_keys) >= 2, (
|
||||
f"expected at least two multi-install-safe integrations, got {ordered_keys}"
|
||||
)
|
||||
|
||||
project_root = tmp_path / "project"
|
||||
project_root.mkdir()
|
||||
runner = CliRunner()
|
||||
|
||||
# Install every safe integration once into a single project, then assert
|
||||
# pairwise manifest isolation. Each safe integration writes only to its
|
||||
# own (disjoint) directories and always records what it writes, so a
|
||||
# manifest's contents are independent of install order and of which other
|
||||
# integrations are co-installed. The parametrized rotations keep the
|
||||
# aggregate setup while placing each safe integration first once, so each
|
||||
# one still exercises the `specify init --integration ...` path.
|
||||
original_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_root)
|
||||
init_result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
ordered_keys[0],
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert init_result.exit_code == 0, init_result.output
|
||||
|
||||
for key in ordered_keys[1:]:
|
||||
install_result = runner.invoke(
|
||||
app,
|
||||
["integration", "install", key, "--script", "sh"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
assert install_result.exit_code == 0, install_result.output
|
||||
finally:
|
||||
os.chdir(original_cwd)
|
||||
|
||||
integrations_dir = project_root / ".specify" / "integrations"
|
||||
manifests = {}
|
||||
for key in ordered_keys:
|
||||
manifest = json.loads(
|
||||
(integrations_dir / f"{key}.manifest.json").read_text(encoding="utf-8")
|
||||
)
|
||||
files = manifest.get("files", {})
|
||||
assert isinstance(files, dict), f"{key} manifest files must be an object"
|
||||
manifests[key] = set(files.keys())
|
||||
|
||||
for first, second in _multi_install_safe_pairs():
|
||||
overlap = manifests[first] & manifests[second]
|
||||
assert not overlap, (
|
||||
f"{first} and {second} are declared multi-install safe but both manage "
|
||||
f"these files: {sorted(overlap)}"
|
||||
)
|
||||
|
||||
|
||||
class TestCatalogParity:
|
||||
"""The discovery catalog must list every registered integration."""
|
||||
|
||||
def test_every_registered_integration_is_in_catalog(self):
|
||||
"""``integrations/catalog.json`` must cover every registry key.
|
||||
|
||||
The catalog is the discovery manifest; an integration that is
|
||||
registered, registrar-aligned and registry-tested but missing from
|
||||
the catalog is undiscoverable through it. ``generic`` is exempt —
|
||||
it is the no-fixed-directory fallback, not a catalogued agent.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
catalog = json.loads(
|
||||
(repo_root / "integrations" / "catalog.json").read_text(encoding="utf-8")
|
||||
)
|
||||
catalogued = set(catalog["integrations"])
|
||||
registered = set(INTEGRATION_REGISTRY) - {"generic"}
|
||||
missing = sorted(registered - catalogued)
|
||||
assert not missing, f"integrations missing from catalog.json: {missing}"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Regression tests for SKILL.md frontmatter quoting (#3391).
|
||||
|
||||
The skills setup path builds SKILL.md frontmatter by hand with
|
||||
double-quoted values. A double-quoted YAML scalar cannot carry a raw
|
||||
newline (the parser folds it to a space) or a control character (the
|
||||
reader rejects the document), so descriptions taken from template
|
||||
frontmatter must be escaped by the YAML emitter.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.base import yaml_quote
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
MULTILINE = "first line\nsecond line\n"
|
||||
CONTROL = "ding\aling"
|
||||
|
||||
HOSTILE_TEMPLATE = """---
|
||||
description: |
|
||||
first line
|
||||
second line
|
||||
---
|
||||
|
||||
Body of the command.
|
||||
"""
|
||||
|
||||
CONTROL_TEMPLATE = """---
|
||||
description: "ding\\aling"
|
||||
---
|
||||
|
||||
Body of the command.
|
||||
"""
|
||||
|
||||
|
||||
def _parse_frontmatter(skill_file: Path) -> dict:
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n")
|
||||
return yaml.safe_load(content.split("---", 2)[1])
|
||||
|
||||
|
||||
def _fake_templates(tmp_path: Path, body: str) -> Path:
|
||||
templates = tmp_path / "templates"
|
||||
templates.mkdir(exist_ok=True)
|
||||
(templates / "plan.md").write_text(body, encoding="utf-8")
|
||||
return templates
|
||||
|
||||
|
||||
class TestYamlQuote:
|
||||
def test_simple_value_keeps_plain_double_quoted_form(self):
|
||||
assert yaml_quote("speckit-plan") == '"speckit-plan"'
|
||||
assert yaml_quote('say "hi"') == '"say \\"hi\\""'
|
||||
assert yaml_quote("back\\slash") == '"back\\\\slash"'
|
||||
|
||||
def test_multiline_value_round_trips(self):
|
||||
quoted = yaml_quote(MULTILINE)
|
||||
assert "\n" not in quoted
|
||||
assert yaml.safe_load(quoted) == MULTILINE
|
||||
|
||||
def test_control_character_round_trips(self):
|
||||
quoted = yaml_quote(CONTROL)
|
||||
assert "\a" not in quoted
|
||||
assert yaml.safe_load(quoted) == CONTROL
|
||||
|
||||
|
||||
class TestSkillFrontmatterQuoting:
|
||||
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
|
||||
integration = get_integration("agy")
|
||||
monkeypatch.setattr(
|
||||
integration,
|
||||
"shared_commands_dir",
|
||||
lambda: _fake_templates(tmp_path, template),
|
||||
)
|
||||
manifest = IntegrationManifest("agy", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
return skill_files[0]
|
||||
|
||||
def test_multiline_description_survives(self, tmp_path, monkeypatch):
|
||||
skill_file = self._generate(tmp_path, monkeypatch, HOSTILE_TEMPLATE)
|
||||
fm = _parse_frontmatter(skill_file)
|
||||
assert fm["description"] == MULTILINE
|
||||
|
||||
def test_control_character_description_parses(self, tmp_path, monkeypatch):
|
||||
skill_file = self._generate(tmp_path, monkeypatch, CONTROL_TEMPLATE)
|
||||
fm = _parse_frontmatter(skill_file)
|
||||
assert fm["description"] == CONTROL
|
||||
|
||||
|
||||
class TestHermesSkillFrontmatterQuoting:
|
||||
def test_multiline_description_survives(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
integration = get_integration("hermes")
|
||||
monkeypatch.setattr(
|
||||
integration,
|
||||
"shared_commands_dir",
|
||||
lambda: _fake_templates(tmp_path, HOSTILE_TEMPLATE),
|
||||
)
|
||||
manifest = IntegrationManifest("hermes", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
|
||||
fm = _parse_frontmatter(skill_files[0])
|
||||
assert fm["description"] == MULTILINE
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Shared fixtures and helpers for `specify self upgrade` tests.
|
||||
|
||||
These helpers patch subprocess, PATH lookup, and release-tag resolution so
|
||||
the focused test modules stay isolated from the real environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli._version import (
|
||||
_InstallMethod,
|
||||
_UpgradePlan,
|
||||
_assemble_installer_argv,
|
||||
_detect_install_method,
|
||||
_verify_upgrade,
|
||||
)
|
||||
from tests.conftest import strip_ansi
|
||||
from tests.http_helpers import mock_urlopen_response
|
||||
|
||||
__all__ = (
|
||||
"SENTINEL_GH_TOKEN",
|
||||
"SENTINEL_GITHUB_TOKEN",
|
||||
"_InstallMethod",
|
||||
"_UpgradePlan",
|
||||
"_assemble_installer_argv",
|
||||
"_completed_process",
|
||||
"_detect_install_method",
|
||||
"_verify_upgrade",
|
||||
"mock_urlopen_response",
|
||||
"requires_posix",
|
||||
"runner",
|
||||
"strip_ansi",
|
||||
)
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
# Some installer error-path tests create a relative `./uv` fixture, `chdir`
|
||||
# into the tmp dir, and assert POSIX executable-bit semantics (chmod / X_OK).
|
||||
# None of that maps cleanly onto Windows: `os.access(path, X_OK)` ignores the
|
||||
# mode bits, and pytest cannot rmtree a tmp dir that is still the cwd, so the
|
||||
# fixtures raise PermissionError during teardown. Skip these on Windows — the
|
||||
# realistic absolute-path and bare-PATH-command branches stay covered there.
|
||||
requires_posix = pytest.mark.skipif(
|
||||
os.name == "nt",
|
||||
reason="relative-path / executable-bit semantics are POSIX-only",
|
||||
)
|
||||
|
||||
SENTINEL_GH_TOKEN = "SENTINEL-GH-TOKEN-VALUE"
|
||||
SENTINEL_GITHUB_TOKEN = "SENTINEL-GITHUB-TOKEN-VALUE"
|
||||
|
||||
|
||||
def _completed_process(
|
||||
returncode: int, stdout: str = "", stderr: str = ""
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Build a subprocess.CompletedProcess for installer / verification calls."""
|
||||
return subprocess.CompletedProcess(
|
||||
args=["mocked"],
|
||||
returncode=returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
)
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Consistency checks for agent configuration across runtime surfaces."""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli import AGENT_CONFIG
|
||||
from specify_cli.extensions import CommandRegistrar
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"amp",
|
||||
"agy",
|
||||
"auggie",
|
||||
"claude",
|
||||
"cline",
|
||||
"codebuddy",
|
||||
"codex",
|
||||
"cursor-agent",
|
||||
"devin",
|
||||
"firebender",
|
||||
"forge",
|
||||
"gemini",
|
||||
"copilot",
|
||||
"goose",
|
||||
"hermes",
|
||||
"bob",
|
||||
"junie",
|
||||
"kilocode",
|
||||
"kimi",
|
||||
"kiro-cli",
|
||||
"lingma",
|
||||
"vibe",
|
||||
"omp",
|
||||
"opencode",
|
||||
"pi",
|
||||
"qodercli",
|
||||
"qwen",
|
||||
"rovodev",
|
||||
"shai",
|
||||
"tabnine",
|
||||
"trae",
|
||||
"zcode",
|
||||
"zed",
|
||||
]
|
||||
|
||||
|
||||
def _issue_template(path: str) -> dict:
|
||||
return yaml.safe_load((REPO_ROOT / path).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _body_item_by_id(template: dict, item_id: str) -> dict:
|
||||
for item in template["body"]:
|
||||
if item.get("id") == item_id:
|
||||
return item
|
||||
raise AssertionError(f"Expected issue template body item {item_id!r}")
|
||||
|
||||
|
||||
def _dropdown_options(path: str, item_id: str) -> list[str]:
|
||||
item = _body_item_by_id(_issue_template(path), item_id)
|
||||
return item["attributes"]["options"]
|
||||
|
||||
|
||||
def _normalized_markdown(text: str) -> str:
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _markdown_value_containing(path: str, marker: str) -> str:
|
||||
template = _issue_template(path)
|
||||
normalized_marker = _normalized_markdown(marker)
|
||||
for item in template["body"]:
|
||||
if item.get("type") != "markdown":
|
||||
continue
|
||||
value = item["attributes"]["value"]
|
||||
if normalized_marker in _normalized_markdown(value):
|
||||
return value
|
||||
raise AssertionError(f"Expected issue template markdown containing {marker!r}")
|
||||
|
||||
|
||||
def _markdown_paragraph_containing(path: str, marker: str) -> str:
|
||||
value = _markdown_value_containing(path, marker)
|
||||
normalized_marker = _normalized_markdown(marker)
|
||||
for paragraph in re.split(r"\n\s*\n", value):
|
||||
if normalized_marker in _normalized_markdown(paragraph):
|
||||
return paragraph
|
||||
raise AssertionError(f"Expected issue template paragraph containing {marker!r}")
|
||||
|
||||
|
||||
def _supported_agent_names_from_agent_request_template() -> list[str]:
|
||||
marker = "**Currently supported agents**:"
|
||||
paragraph = _markdown_paragraph_containing(
|
||||
".github/ISSUE_TEMPLATE/agent_request.yml",
|
||||
marker,
|
||||
)
|
||||
supported_agents_text = _normalized_markdown(paragraph).split(marker, 1)[1].strip()
|
||||
return [agent.strip() for agent in supported_agents_text.split(",")]
|
||||
|
||||
|
||||
class TestAgentConfigConsistency:
|
||||
"""Ensure agent configuration stays synchronized across key surfaces."""
|
||||
|
||||
def test_issue_template_agent_lists_match_runtime_integrations(self):
|
||||
"""GitHub issue templates should list all concrete built-in agents."""
|
||||
concrete_agent_keys = set(AGENT_CONFIG) - {"generic"}
|
||||
issue_template_agent_keys = set(ISSUE_TEMPLATE_AGENT_KEYS)
|
||||
|
||||
missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys)
|
||||
unexpected_agent_keys = sorted(issue_template_agent_keys - concrete_agent_keys)
|
||||
duplicate_agent_keys = sorted(
|
||||
key
|
||||
for key in issue_template_agent_keys
|
||||
if ISSUE_TEMPLATE_AGENT_KEYS.count(key) > 1
|
||||
)
|
||||
assert not missing_agent_keys, (
|
||||
"Issue template agent list is missing AGENT_CONFIG keys: "
|
||||
f"{missing_agent_keys}"
|
||||
)
|
||||
assert not unexpected_agent_keys, (
|
||||
"Issue template agent list includes unknown AGENT_CONFIG keys: "
|
||||
f"{unexpected_agent_keys}"
|
||||
)
|
||||
assert not duplicate_agent_keys, (
|
||||
"Issue template agent list contains duplicate keys: "
|
||||
f"{duplicate_agent_keys}"
|
||||
)
|
||||
|
||||
issue_template_agent_names = [
|
||||
AGENT_CONFIG[key]["name"] for key in ISSUE_TEMPLATE_AGENT_KEYS
|
||||
]
|
||||
assert "Generic (bring your own agent)" not in issue_template_agent_names
|
||||
|
||||
bug_options = _dropdown_options(
|
||||
".github/ISSUE_TEMPLATE/bug_report.yml",
|
||||
"ai-agent",
|
||||
)
|
||||
assert bug_options == issue_template_agent_names + ["Not applicable"]
|
||||
|
||||
feature_options = _dropdown_options(
|
||||
".github/ISSUE_TEMPLATE/feature_request.yml",
|
||||
"ai-agent",
|
||||
)
|
||||
assert feature_options == [
|
||||
"All agents",
|
||||
*issue_template_agent_names,
|
||||
"Not applicable",
|
||||
]
|
||||
|
||||
assert (
|
||||
_supported_agent_names_from_agent_request_template()
|
||||
== issue_template_agent_names
|
||||
)
|
||||
|
||||
def test_runtime_config_uses_kiro_cli_and_removes_q(self):
|
||||
"""AGENT_CONFIG should include kiro-cli and exclude legacy q."""
|
||||
assert "kiro-cli" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["kiro-cli"]["folder"] == ".kiro/"
|
||||
assert AGENT_CONFIG["kiro-cli"]["commands_subdir"] == "prompts"
|
||||
assert "q" not in AGENT_CONFIG
|
||||
|
||||
def test_extension_registrar_uses_kiro_cli_and_removes_q(self):
|
||||
"""Extension command registrar should target .kiro/prompts."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "kiro-cli" in cfg
|
||||
assert cfg["kiro-cli"]["dir"] == ".kiro/prompts"
|
||||
assert "q" not in cfg
|
||||
|
||||
def test_extension_registrar_includes_codex(self):
|
||||
"""Extension command registrar should include codex targeting .agents/skills."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "codex" in cfg
|
||||
assert cfg["codex"]["dir"] == ".agents/skills"
|
||||
assert cfg["codex"]["extension"] == "/SKILL.md"
|
||||
|
||||
def test_runtime_codex_uses_native_skills(self):
|
||||
"""Codex runtime config should point at .agents/skills."""
|
||||
assert AGENT_CONFIG["codex"]["folder"] == ".agents/"
|
||||
assert AGENT_CONFIG["codex"]["commands_subdir"] == "skills"
|
||||
|
||||
def test_devcontainer_kiro_installer_uses_pinned_checksum(self):
|
||||
"""Devcontainer installer should always verify Kiro installer via pinned SHA256."""
|
||||
post_create_text = (REPO_ROOT / ".devcontainer" / "post-create.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert (
|
||||
'KIRO_INSTALLER_SHA256="7487a65cf310b7fb59b357c4b5e6e3f3259d383f4394ecedb39acf70f307cffb"'
|
||||
in post_create_text
|
||||
)
|
||||
assert "sha256sum -c -" in post_create_text
|
||||
assert "KIRO_SKIP_KIRO_INSTALLER_VERIFY" not in post_create_text
|
||||
|
||||
# --- Tabnine CLI consistency checks ---
|
||||
|
||||
def test_runtime_config_includes_tabnine(self):
|
||||
"""AGENT_CONFIG should include tabnine with correct folder and subdir."""
|
||||
assert "tabnine" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["tabnine"]["folder"] == ".tabnine/agent/"
|
||||
assert AGENT_CONFIG["tabnine"]["commands_subdir"] == "commands"
|
||||
assert AGENT_CONFIG["tabnine"]["requires_cli"] is True
|
||||
assert AGENT_CONFIG["tabnine"]["install_url"] is not None
|
||||
|
||||
def test_extension_registrar_includes_tabnine(self):
|
||||
"""CommandRegistrar.AGENT_CONFIGS should include tabnine with correct TOML config."""
|
||||
from specify_cli.extensions import CommandRegistrar
|
||||
|
||||
assert "tabnine" in CommandRegistrar.AGENT_CONFIGS
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS["tabnine"]
|
||||
assert cfg["dir"] == ".tabnine/agent/commands"
|
||||
assert cfg["format"] == "toml"
|
||||
assert cfg["args"] == "{{args}}"
|
||||
assert cfg["extension"] == ".toml"
|
||||
|
||||
def test_agent_config_includes_tabnine(self):
|
||||
"""AGENT_CONFIG should include tabnine."""
|
||||
assert "tabnine" in AGENT_CONFIG
|
||||
|
||||
# --- Kimi Code CLI consistency checks ---
|
||||
|
||||
def test_kimi_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include kimi with correct folder and commands_subdir."""
|
||||
assert "kimi" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["kimi"]["folder"] == ".kimi-code/"
|
||||
assert AGENT_CONFIG["kimi"]["commands_subdir"] == "skills"
|
||||
assert AGENT_CONFIG["kimi"]["requires_cli"] is True
|
||||
|
||||
def test_kimi_in_extension_registrar(self):
|
||||
"""Extension command registrar should include kimi using .kimi-code/skills and SKILL.md."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "kimi" in cfg
|
||||
kimi_cfg = cfg["kimi"]
|
||||
assert kimi_cfg["dir"] == ".kimi-code/skills"
|
||||
assert kimi_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_agent_config_includes_kimi(self):
|
||||
"""AGENT_CONFIG should include kimi."""
|
||||
assert "kimi" in AGENT_CONFIG
|
||||
|
||||
# --- Trae IDE consistency checks ---
|
||||
|
||||
def test_trae_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include trae with correct folder and commands_subdir."""
|
||||
assert "trae" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["trae"]["folder"] == ".trae/"
|
||||
assert AGENT_CONFIG["trae"]["commands_subdir"] == "skills"
|
||||
assert AGENT_CONFIG["trae"]["requires_cli"] is False
|
||||
assert AGENT_CONFIG["trae"]["install_url"] is None
|
||||
|
||||
def test_trae_in_extension_registrar(self):
|
||||
"""Extension command registrar should include trae using .trae/rules and markdown, if present."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "trae" in cfg
|
||||
trae_cfg = cfg["trae"]
|
||||
assert trae_cfg["format"] == "markdown"
|
||||
assert trae_cfg["args"] == "$ARGUMENTS"
|
||||
assert trae_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_agent_config_includes_trae(self):
|
||||
"""AGENT_CONFIG should include trae."""
|
||||
assert "trae" in AGENT_CONFIG
|
||||
|
||||
# --- Pi Coding Agent consistency checks ---
|
||||
|
||||
def test_pi_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include pi with correct folder and commands_subdir."""
|
||||
assert "pi" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["pi"]["folder"] == ".pi/"
|
||||
assert AGENT_CONFIG["pi"]["commands_subdir"] == "prompts"
|
||||
assert AGENT_CONFIG["pi"]["requires_cli"] is True
|
||||
assert AGENT_CONFIG["pi"]["install_url"] is not None
|
||||
|
||||
def test_pi_in_extension_registrar(self):
|
||||
"""Extension command registrar should include pi using .pi/prompts."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "pi" in cfg
|
||||
pi_cfg = cfg["pi"]
|
||||
assert pi_cfg["dir"] == ".pi/prompts"
|
||||
assert pi_cfg["format"] == "markdown"
|
||||
assert pi_cfg["args"] == "$ARGUMENTS"
|
||||
assert pi_cfg["extension"] == ".md"
|
||||
|
||||
def test_agent_config_includes_pi(self):
|
||||
"""AGENT_CONFIG should include pi."""
|
||||
assert "pi" in AGENT_CONFIG
|
||||
|
||||
# --- Goose consistency checks ---
|
||||
|
||||
def test_goose_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include goose with correct folder and commands_subdir."""
|
||||
assert "goose" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["goose"]["folder"] == ".goose/"
|
||||
assert AGENT_CONFIG["goose"]["commands_subdir"] == "recipes"
|
||||
assert AGENT_CONFIG["goose"]["requires_cli"] is True
|
||||
|
||||
def test_goose_in_extension_registrar(self):
|
||||
"""Extension command registrar should include goose targeting .goose/recipes."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "goose" in cfg
|
||||
assert cfg["goose"]["dir"] == ".goose/recipes"
|
||||
assert cfg["goose"]["format"] == "yaml"
|
||||
assert cfg["goose"]["args"] == "{{args}}"
|
||||
|
||||
def test_agent_config_includes_goose(self):
|
||||
"""AGENT_CONFIG should include goose."""
|
||||
assert "goose" in AGENT_CONFIG
|
||||
|
||||
# --- invoke_separator propagation checks ---
|
||||
|
||||
def test_skills_agents_have_hyphen_invoke_separator_in_agent_configs(self):
|
||||
"""Skills-based agents must expose invoke_separator='-' in AGENT_CONFIGS.
|
||||
|
||||
SkillsIntegration sets ``invoke_separator = "-"`` as a class attribute,
|
||||
but individual skills integrations (claude, codex, …) do not repeat it in
|
||||
their ``registrar_config`` dicts. ``_build_agent_configs()`` must
|
||||
propagate the class attribute so that ``register_commands()`` resolves
|
||||
``__SPECKIT_COMMAND_*__`` tokens with the correct hyphen separator.
|
||||
"""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
skills_agents = [
|
||||
key for key, c in cfg.items() if c.get("extension") == "/SKILL.md"
|
||||
]
|
||||
assert skills_agents, (
|
||||
"Expected at least one skills-based agent in AGENT_CONFIGS"
|
||||
)
|
||||
for agent in skills_agents:
|
||||
assert cfg[agent].get("invoke_separator") == "-", (
|
||||
f"Skills agent '{agent}' has invoke_separator="
|
||||
f"{cfg[agent].get('invoke_separator')!r} in AGENT_CONFIGS; "
|
||||
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
|
||||
)
|
||||
|
||||
def test_codex_dev_no_symlink_policy_in_agent_config(self):
|
||||
"""Codex dev installs must expose the no-symlink policy as metadata."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert cfg["codex"].get("dev_no_symlink") is True
|
||||
|
||||
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
|
||||
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
|
||||
when registered for a skills-based agent (e.g. claude).
|
||||
|
||||
Regression guard: before the fix, _build_agent_configs() did not
|
||||
propagate invoke_separator from the integration class, so
|
||||
register_commands() fell back to '.' and emitted /speckit.specify instead
|
||||
of /speckit-specify for skills agents.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
ext_dir = repo_root / "extensions" / "git"
|
||||
cmd_source = ext_dir / "commands" / "speckit.git.feature.md"
|
||||
assert cmd_source.exists(), (
|
||||
f"Git extension command source not found at {cmd_source}"
|
||||
)
|
||||
assert "__SPECKIT_COMMAND_SPECIFY__" in cmd_source.read_text(
|
||||
encoding="utf-8"
|
||||
), (
|
||||
"Expected __SPECKIT_COMMAND_SPECIFY__ token in speckit.git.feature.md; "
|
||||
"check that the file uses the token rather than a hard-coded ref."
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [
|
||||
{"name": "speckit.git.feature", "file": "commands/speckit.git.feature.md"}
|
||||
]
|
||||
|
||||
registered = registrar.register_commands(
|
||||
"claude",
|
||||
commands,
|
||||
"git",
|
||||
ext_dir,
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert "speckit.git.feature" in registered
|
||||
skill_file = (
|
||||
tmp_path / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
|
||||
)
|
||||
assert skill_file.exists(), (
|
||||
f"Expected Claude skill file not found at {skill_file}"
|
||||
)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "/speckit-specify" in content, (
|
||||
"Expected '/speckit-specify' (hyphen) in generated Claude skill for git.feature; "
|
||||
"__SPECKIT_COMMAND_SPECIFY__ was not resolved with the correct separator."
|
||||
)
|
||||
# Negative lookbehind (?<![a-zA-Z0-9_]) excludes file-path occurrences
|
||||
# such as 'source: git:commands/speckit.git.feature.md' in frontmatter,
|
||||
# where the '/' is a path separator preceded by a word character.
|
||||
assert not re.search(r"(?<![a-zA-Z0-9_])/speckit\.[a-z]", content), (
|
||||
"Found dot-notation command ref (/speckit.<cmd>) in generated Claude skill. "
|
||||
"Skills agents must use hyphen notation."
|
||||
)
|
||||
|
||||
# --- RovoDev consistency checks ---
|
||||
|
||||
def test_rovodev_in_agent_config(self):
|
||||
"""AGENT_CONFIG should include rovodev with skills-based scaffold metadata."""
|
||||
assert "rovodev" in AGENT_CONFIG
|
||||
assert AGENT_CONFIG["rovodev"]["folder"] == ".rovodev/"
|
||||
assert AGENT_CONFIG["rovodev"]["commands_subdir"] == "skills"
|
||||
assert AGENT_CONFIG["rovodev"]["requires_cli"] is True
|
||||
|
||||
def test_rovodev_in_extension_registrar(self):
|
||||
"""CommandRegistrar.AGENT_CONFIGS should include rovodev skill scaffold metadata."""
|
||||
cfg = CommandRegistrar.AGENT_CONFIGS
|
||||
|
||||
assert "rovodev" in cfg
|
||||
rovodev_cfg = cfg["rovodev"]
|
||||
assert rovodev_cfg["dir"] == ".rovodev/skills"
|
||||
assert rovodev_cfg["format"] == "markdown"
|
||||
assert rovodev_cfg["args"] == "$ARGUMENTS"
|
||||
assert rovodev_cfg["extension"] == "/SKILL.md"
|
||||
|
||||
def test_agent_config_includes_rovodev(self):
|
||||
"""AGENT_CONFIG should include rovodev."""
|
||||
assert "rovodev" in AGENT_CONFIG
|
||||
@@ -0,0 +1,974 @@
|
||||
"""Tests for the authentication provider registry and config-driven HTTP helpers.
|
||||
|
||||
Covers:
|
||||
- Config loading (auth.json parsing, validation, permission warning)
|
||||
- Registry mechanics (_register, get_provider, duplicate/empty-key guards)
|
||||
- GitHubAuth — bearer headers
|
||||
- AzureDevOpsAuth — basic-pat, bearer, azure-cli, azure-ad headers
|
||||
- Host matching (find_entries_for_url)
|
||||
- open_url — config-driven auth with fallthrough and redirect stripping
|
||||
- build_request — single-shot request construction
|
||||
- _fetch_latest_release_tag() delegation
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.authentication import AUTH_REGISTRY, _register, get_provider
|
||||
from specify_cli.authentication.azure_devops import AzureDevOpsAuth
|
||||
from specify_cli.authentication.base import AuthProvider
|
||||
from specify_cli.authentication.config import (
|
||||
AuthConfigEntry,
|
||||
find_entries_for_url,
|
||||
load_auth_config,
|
||||
)
|
||||
from specify_cli.authentication.github import GitHubAuth
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> AuthConfigEntry:
|
||||
"""Build a standard GitHub config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("github.com", "api.github.com", "raw.githubusercontent.com", "codeload.github.com"),
|
||||
provider="github",
|
||||
auth="bearer",
|
||||
token=token,
|
||||
token_env=token_env if token is None else None,
|
||||
)
|
||||
|
||||
|
||||
def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry:
|
||||
"""Build an ADO basic-pat config entry."""
|
||||
return AuthConfigEntry(
|
||||
hosts=("dev.azure.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env=token_env,
|
||||
)
|
||||
|
||||
|
||||
class _StubProvider(AuthProvider):
|
||||
"""Minimal concrete provider for registry mechanics tests."""
|
||||
|
||||
key = "stub-provider"
|
||||
supported_auth_schemes = ("bearer",)
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadAuthConfig:
|
||||
def test_missing_file_returns_empty(self, tmp_path):
|
||||
assert load_auth_config(tmp_path / "nonexistent.json") == []
|
||||
|
||||
def test_valid_github_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": "GH_TOKEN",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "github"
|
||||
assert entries[0].auth == "bearer"
|
||||
assert entries[0].token_env == "GH_TOKEN"
|
||||
|
||||
def test_valid_ado_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "AZURE_DEVOPS_PAT",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].provider == "azure-devops"
|
||||
assert entries[0].auth == "basic-pat"
|
||||
|
||||
def test_inline_token(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token": "ghp_inline_token",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].token == "ghp_inline_token"
|
||||
|
||||
def test_azure_ad_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
"client_id": "cid",
|
||||
"client_secret_env": "SECRET",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-ad"
|
||||
assert entries[0].tenant_id == "tid"
|
||||
|
||||
def test_azure_cli_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-cli",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].auth == "azure-cli"
|
||||
|
||||
def test_multiple_entries(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [
|
||||
{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"},
|
||||
{"hosts": ["dev.azure.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "ADO_PAT"},
|
||||
]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 2
|
||||
|
||||
# -- Negative: validation errors --
|
||||
|
||||
def test_invalid_json_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("not json")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_not_object_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text("[]")
|
||||
with pytest.raises(ValueError, match="JSON object"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_providers_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({"foo": "bar"}))
|
||||
with pytest.raises(ValueError, match="providers"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_empty_hosts_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": [], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_missing_provider_key_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unsupported_auth_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "ntlm", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_bearer_without_token_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="token"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_azure_ad_missing_fields_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": "tid",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="azure-ad"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_unknown_provider_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["example.com"], "provider": "gitlab", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="unknown provider"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_incompatible_provider_scheme_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "basic-pat",
|
||||
"token_env": "X",
|
||||
}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="does not support"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_dangerous_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*github.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_multi_wildcard_host_raises(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.*.example.com"], "provider": "github", "auth": "bearer", "token_env": "X"}]
|
||||
}))
|
||||
with pytest.raises(ValueError, match="invalid host pattern"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
def test_valid_star_dot_host_accepted(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["*.visualstudio.com"], "provider": "azure-devops", "auth": "basic-pat", "token_env": "X"}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].hosts == ("*.visualstudio.com",)
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits not supported on Windows")
|
||||
def test_world_readable_warns(self, tmp_path):
|
||||
import stat
|
||||
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{"hosts": ["github.com"], "provider": "github", "auth": "bearer", "token_env": "GH_TOKEN"}]
|
||||
}))
|
||||
cfg.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IROTH)
|
||||
with pytest.warns(UserWarning, match="readable by group"):
|
||||
load_auth_config(cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Host matching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFindEntriesForUrl:
|
||||
def test_exact_match(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com/org/repo", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_wildcard_match(self):
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("*.visualstudio.com",),
|
||||
provider="azure-devops",
|
||||
auth="basic-pat",
|
||||
token_env="ADO_PAT",
|
||||
)
|
||||
result = find_entries_for_url("https://myorg.visualstudio.com/project", [entry])
|
||||
assert result == [entry]
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://evil.example.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_no_match_for_lookalike_host(self):
|
||||
entry = _github_entry()
|
||||
result = find_entries_for_url("https://github.com.evil.com/file", [entry])
|
||||
assert result == []
|
||||
|
||||
def test_empty_url_returns_empty(self):
|
||||
assert find_entries_for_url("", [_github_entry()]) == []
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"https://[::1", # unterminated ipv6 bracket
|
||||
"https://[not-an-ip]/file", # bracketed non-ip host
|
||||
],
|
||||
)
|
||||
def test_malformed_url_returns_empty(self, url):
|
||||
# A malformed authority makes urlparse/hostname raise ValueError.
|
||||
# Since no entry can match such a URL, this must return no matches
|
||||
# (like a host-less URL) rather than leaking a raw ValueError out of
|
||||
# the shared HTTP client.
|
||||
assert find_entries_for_url(url, [_github_entry()]) == []
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
assert find_entries_for_url("https://github.com/org/repo", []) == []
|
||||
|
||||
def test_multiple_matches_returned(self):
|
||||
e1 = _github_entry(token_env="GH_TOKEN")
|
||||
e2 = _github_entry(token_env="GITHUB_TOKEN")
|
||||
result = find_entries_for_url("https://github.com/org/repo", [e1, e2])
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry mechanics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthRegistry:
|
||||
def test_github_registered(self):
|
||||
assert "github" in AUTH_REGISTRY
|
||||
|
||||
def test_azure_devops_registered(self):
|
||||
assert "azure-devops" in AUTH_REGISTRY
|
||||
|
||||
def test_get_provider_returns_github(self):
|
||||
assert isinstance(get_provider("github"), GitHubAuth)
|
||||
|
||||
def test_get_provider_returns_azure_devops(self):
|
||||
assert isinstance(get_provider("azure-devops"), AzureDevOpsAuth)
|
||||
|
||||
def test_get_provider_unknown_returns_none(self):
|
||||
assert get_provider("does-not-exist") is None
|
||||
|
||||
def test_register_duplicate_raises_key_error(self):
|
||||
class _UniqueStub(_StubProvider):
|
||||
key = "__test_duplicate__"
|
||||
|
||||
try:
|
||||
_register(_UniqueStub())
|
||||
with pytest.raises(KeyError, match="already registered"):
|
||||
_register(_UniqueStub())
|
||||
finally:
|
||||
AUTH_REGISTRY.pop("__test_duplicate__", None)
|
||||
|
||||
def test_register_empty_key_raises_value_error(self):
|
||||
class _EmptyKey(_StubProvider):
|
||||
key = ""
|
||||
|
||||
with pytest.raises(ValueError, match="empty key"):
|
||||
_register(_EmptyKey())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHubAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGitHubAuth:
|
||||
def test_bearer_headers(self):
|
||||
assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError, match="basic-pat"):
|
||||
GitHubAuth().auth_headers("tok", "basic-pat")
|
||||
|
||||
def test_resolve_token_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", "env-token")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "env-token"
|
||||
|
||||
def test_resolve_token_inline(self):
|
||||
assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " my-token ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) == "my-token"
|
||||
|
||||
def test_resolve_token_empty_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.setenv("GH_TOKEN", " ")
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_resolve_token_missing_env_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("GH_TOKEN", raising=False)
|
||||
assert GitHubAuth().resolve_token(_github_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert GitHubAuth.key == "github"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
assert GitHubAuth.supported_auth_schemes == ("bearer",)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AzureDevOpsAuth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAzureDevOpsAuth:
|
||||
def test_basic_pat_headers(self):
|
||||
headers = AzureDevOpsAuth().auth_headers("my-pat", "basic-pat")
|
||||
encoded = base64.b64encode(b":my-pat").decode("ascii")
|
||||
assert headers == {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
def test_basic_pat_format(self):
|
||||
header = AzureDevOpsAuth().auth_headers("test-pat", "basic-pat")["Authorization"]
|
||||
raw = base64.b64decode(header[len("Basic "):]).decode("ascii")
|
||||
assert raw == ":test-pat"
|
||||
|
||||
def test_bearer_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "bearer") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_cli_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-cli") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_azure_ad_headers(self):
|
||||
assert AzureDevOpsAuth().auth_headers("tok", "azure-ad") == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_unsupported_scheme_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
AzureDevOpsAuth().auth_headers("tok", "ntlm")
|
||||
|
||||
def test_resolve_token_basic_pat(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", "my-pat")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_strips_whitespace(self, monkeypatch):
|
||||
monkeypatch.setenv("AZURE_DEVOPS_PAT", " my-pat ")
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) == "my-pat"
|
||||
|
||||
def test_resolve_token_missing_returns_none(self, monkeypatch):
|
||||
monkeypatch.delenv("AZURE_DEVOPS_PAT", raising=False)
|
||||
assert AzureDevOpsAuth().resolve_token(_ado_basic_entry()) is None
|
||||
|
||||
def test_key(self):
|
||||
assert AzureDevOpsAuth.key == "azure-devops"
|
||||
|
||||
def test_supported_schemes(self):
|
||||
schemes = AzureDevOpsAuth.supported_auth_schemes
|
||||
assert "basic-pat" in schemes
|
||||
assert "bearer" in schemes
|
||||
assert "azure-cli" in schemes
|
||||
assert "azure-ad" in schemes
|
||||
|
||||
def test_resolve_token_azure_cli_success(self):
|
||||
"""azure-cli acquires token via az CLI."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = '{"accessToken": "cli-acquired-token"}'
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "cli-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_cli_failure_returns_none(self):
|
||||
"""azure-cli returns None when az CLI fails."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 1
|
||||
result.stdout = ""
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_cli_not_installed_returns_none(self):
|
||||
"""azure-cli returns None when az is not installed."""
|
||||
from unittest.mock import patch
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", side_effect=OSError("not found")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_success(self, monkeypatch):
|
||||
"""azure-ad acquires token via OAuth2 client credentials."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.read.return_value = b'{"access_token": "ad-acquired-token"}'
|
||||
mock_resp.__enter__ = lambda s: s
|
||||
mock_resp.__exit__ = MagicMock(return_value=False)
|
||||
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "ad-acquired-token"
|
||||
|
||||
def test_resolve_token_azure_ad_missing_secret_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None when client secret env var is missing."""
|
||||
monkeypatch.delenv("MY_SECRET", raising=False)
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_ad_network_error_returns_none(self, monkeypatch):
|
||||
"""azure-ad returns None on network errors."""
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
monkeypatch.setenv("MY_SECRET", "secret-value")
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-ad",
|
||||
tenant_id="tid", client_id="cid", client_secret_env="MY_SECRET",
|
||||
)
|
||||
with patch("urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("connection refused")):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url / build_request — positive tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttp:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_build_request_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_build_request_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://evil.example.com/file")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_no_auth_when_no_config(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
self._set_config(monkeypatch, [])
|
||||
req = build_request("https://github.com/org/repo")
|
||||
assert "Authorization" not in req.headers
|
||||
|
||||
def test_build_request_extra_headers(self, monkeypatch):
|
||||
from specify_cli.authentication.http import build_request
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
req = build_request("https://github.com/api", extra_headers={"Accept": "application/json"})
|
||||
assert req.get_header("Accept") == "application/json"
|
||||
assert req.get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_attaches_auth_for_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
mock_opener = MagicMock()
|
||||
def fake_open(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener.open.side_effect = fake_open
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
open_url("https://github.com/org/repo/catalog.json")
|
||||
assert captured["req"].get_header("Authorization") == "Bearer my-token"
|
||||
|
||||
def test_open_url_no_auth_for_non_matching_host(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "my-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://example.com/file.json")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_no_auth_when_no_config(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
captured = {}
|
||||
def fake_urlopen(req, timeout=None):
|
||||
captured["req"] = req
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_urlopen):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert captured["req"].get_header("Authorization") is None
|
||||
|
||||
def test_open_url_falls_through_on_401(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "bad-token")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
call_count = 0
|
||||
def fake_side_effect(req, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = lambda s: s
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
return resp
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = fake_side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener), \
|
||||
patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=fake_side_effect):
|
||||
open_url("https://github.com/org/repo")
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# open_url — negative tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAuthenticatedHttpNegative:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def test_500_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 500, "ISE", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="500"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_404_raises_immediately(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
monkeypatch.setenv("GH_TOKEN", "tok")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = urllib.error.HTTPError("url", 404, "Not Found", {}, None)
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
with pytest.raises(urllib.error.HTTPError, match="404"):
|
||||
open_url("https://github.com/org/repo")
|
||||
|
||||
def test_urlerror_propagates(self, monkeypatch):
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=urllib.error.URLError("refused")):
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
def test_timeout_propagates(self, monkeypatch):
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication.http import open_url
|
||||
self._set_config(monkeypatch, [])
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen",
|
||||
side_effect=socket.timeout("timed out")):
|
||||
with pytest.raises(socket.timeout):
|
||||
open_url("https://example.com/file")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_config caching
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadConfigCaching:
|
||||
def test_config_cached_after_first_load(self, monkeypatch):
|
||||
"""_load_config() should call load_auth_config only once per process."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
# Allow the real load path (no override)
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
entry = _github_entry()
|
||||
call_count = 0
|
||||
|
||||
def fake_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return [entry]
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fake_load):
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
_mod._load_config()
|
||||
|
||||
assert call_count == 1
|
||||
|
||||
def test_cache_bypassed_by_override(self, monkeypatch):
|
||||
"""When _config_override is set, the cache is ignored entirely."""
|
||||
from specify_cli.authentication import http as _mod
|
||||
sentinel = [_github_entry()]
|
||||
monkeypatch.setattr(_mod, "_config_override", sentinel)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
result = _mod._load_config()
|
||||
assert result is sentinel
|
||||
# Cache must not have been populated when override is active
|
||||
assert _mod._config_cache is None
|
||||
|
||||
def test_failed_load_warns_once_and_caches_empty(self, monkeypatch):
|
||||
"""A bad auth.json emits exactly one warning and subsequent calls use cache."""
|
||||
from unittest.mock import patch
|
||||
from specify_cli.authentication import http as _mod
|
||||
import warnings as _warnings
|
||||
monkeypatch.setattr(_mod, "_config_override", None)
|
||||
monkeypatch.setattr(_mod, "_config_cache", None)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fail_load(path=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
raise ValueError("bad config")
|
||||
|
||||
with patch.object(_mod, "load_auth_config", side_effect=fail_load):
|
||||
with _warnings.catch_warnings(record=True) as w:
|
||||
_warnings.simplefilter("always")
|
||||
result1 = _mod._load_config()
|
||||
result2 = _mod._load_config()
|
||||
result3 = _mod._load_config()
|
||||
|
||||
user_warnings = [x for x in w if issubclass(x.category, UserWarning)]
|
||||
assert len(user_warnings) == 1, "Expected exactly one warning"
|
||||
# Loader called only once — subsequent calls used cache
|
||||
assert call_count == 1
|
||||
# All calls returned the cached empty list
|
||||
assert result1 == result2 == result3 == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Redirect stripping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRedirectStripping:
|
||||
def test_redirect_within_hosts_preserves_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com", "codeload.github.com"))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert new_req is not None
|
||||
auth = new_req.get_header("Authorization") or new_req.unredirected_hdrs.get("Authorization")
|
||||
assert auth == "Bearer tok"
|
||||
|
||||
def test_redirect_outside_hosts_strips_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects.githubusercontent.com/asset")
|
||||
assert new_req is not None
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_https_to_http_same_host_redirect_strips_auth(self):
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
new_req = handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://github.com/org/repo")
|
||||
assert new_req is not None
|
||||
assert new_req.headers.get("Authorization") is None
|
||||
assert new_req.unredirected_hdrs.get("Authorization") is None
|
||||
|
||||
def test_redirect_validator_can_reject_before_following_redirect(self):
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
def reject_http(old_url, new_url):
|
||||
if new_url.startswith("http://"):
|
||||
raise urllib.error.URLError("scheme downgrade")
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",), reject_http)
|
||||
req = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
|
||||
with pytest.raises(urllib.error.URLError, match="scheme downgrade"):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"http://github.com/org/repo")
|
||||
|
||||
def test_multi_hop_redirect_within_hosts_preserves_auth(self):
|
||||
"""Auth survives a multi-hop redirect chain within allowed hosts."""
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
hosts = ("github.com", "codeload.github.com", "objects-origin.githubusercontent.com")
|
||||
handler = _StripAuthOnRedirect(hosts)
|
||||
|
||||
# First hop: github.com → codeload.github.com
|
||||
req1 = Request("https://github.com/org/repo", headers={"Authorization": "Bearer tok"})
|
||||
req2 = handler.redirect_request(req1, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://codeload.github.com/org/repo/zip")
|
||||
assert req2 is not None
|
||||
auth2 = req2.get_header("Authorization") or req2.unredirected_hdrs.get("Authorization")
|
||||
assert auth2 == "Bearer tok"
|
||||
|
||||
# Second hop: codeload.github.com → objects-origin.githubusercontent.com
|
||||
req3 = handler.redirect_request(req2, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://objects-origin.githubusercontent.com/asset")
|
||||
assert req3 is not None
|
||||
auth3 = req3.get_header("Authorization") or req3.unredirected_hdrs.get("Authorization")
|
||||
assert auth3 == "Bearer tok"
|
||||
|
||||
def test_malformed_redirect_url_raises_urlerror_not_valueerror(self):
|
||||
"""A redirect to a malformed URL (unterminated IPv6 bracket) surfaces
|
||||
as URLError, which download paths already handle, rather than an
|
||||
unhandled ValueError traceback."""
|
||||
import urllib.error
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
from urllib.request import Request
|
||||
import io
|
||||
|
||||
handler = _StripAuthOnRedirect(("github.com",))
|
||||
req = Request("https://github.com/org/repo")
|
||||
|
||||
with pytest.raises(urllib.error.URLError):
|
||||
handler.redirect_request(req, io.BytesIO(b""), 302, "Found", {},
|
||||
"https://[::1/asset")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fetch_latest_release_tag delegation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFetchLatestReleaseTagDelegation:
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _mod
|
||||
monkeypatch.setattr(_mod, "_config_override", entries)
|
||||
|
||||
def _capture_request(self):
|
||||
import json as _json
|
||||
from unittest.mock import MagicMock
|
||||
captured: dict = {}
|
||||
def side_effect(req, timeout=None):
|
||||
captured["request"] = req
|
||||
body = _json.dumps({"tag_name": "v9.9.9"}).encode()
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = body
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value = resp
|
||||
cm.__exit__.return_value = False
|
||||
return cm
|
||||
return captured, side_effect
|
||||
|
||||
def test_gh_token_forwarded_when_configured(self, monkeypatch):
|
||||
from unittest.mock import MagicMock, patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
monkeypatch.setenv("GH_TOKEN", "forwarded-sentinel")
|
||||
self._set_config(monkeypatch, [_github_entry()])
|
||||
captured, side_effect = self._capture_request()
|
||||
mock_opener = MagicMock()
|
||||
mock_opener.open.side_effect = side_effect
|
||||
with patch("specify_cli.authentication.http.urllib.request.build_opener", return_value=mock_opener):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") == "Bearer forwarded-sentinel"
|
||||
|
||||
def test_no_config_means_no_auth(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Authorization") is None
|
||||
|
||||
def test_accept_header_present(self, monkeypatch):
|
||||
from unittest.mock import patch
|
||||
from specify_cli._version import _fetch_latest_release_tag
|
||||
self._set_config(monkeypatch, [])
|
||||
captured, side_effect = self._capture_request()
|
||||
with patch("specify_cli.authentication.http.urllib.request.urlopen", side_effect=side_effect):
|
||||
_fetch_latest_release_tag()
|
||||
assert captured["request"].get_header("Accept") == "application/vnd.github+json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# github_provider_hosts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGithubProviderHosts:
|
||||
"""Tests for github_provider_hosts() — the GHES host allowlist source."""
|
||||
|
||||
def _set_config(self, monkeypatch, entries):
|
||||
from specify_cli.authentication import http as _auth_http
|
||||
monkeypatch.setattr(_auth_http, "_config_override", entries)
|
||||
|
||||
def test_returns_hosts_from_github_entries(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("ghes.example", "raw.ghes.example"),
|
||||
provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ("ghes.example", "raw.ghes.example")
|
||||
|
||||
def test_empty_when_no_config(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [])
|
||||
assert github_provider_hosts() == ()
|
||||
|
||||
def test_ignores_non_github_providers(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("dev.azure.com",), provider="azure-devops",
|
||||
auth="basic-pat", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ()
|
||||
|
||||
def test_unions_multiple_github_entries(self, monkeypatch):
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
self._set_config(monkeypatch, [
|
||||
AuthConfigEntry(hosts=("ghes.example",), provider="github", auth="bearer", token="t"),
|
||||
AuthConfigEntry(hosts=("github.com",), provider="github", auth="bearer", token="t"),
|
||||
])
|
||||
assert github_provider_hosts() == ("ghes.example", "github.com")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
Unit tests verifying --branch-numbering removal (v0.10.0).
|
||||
|
||||
Branch numbering is now managed entirely by the git extension's config.
|
||||
The --branch-numbering flag was removed from `specify init`.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestBranchNumberingFlagRemoved:
|
||||
"""--branch-numbering flag was removed in v0.10.0."""
|
||||
|
||||
def test_branch_numbering_flag_is_rejected(self, tmp_path: Path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(app, [
|
||||
"init", str(tmp_path / "proj"), "--integration", "claude",
|
||||
"--branch-numbering", "sequential", "--ignore-agent-tools",
|
||||
])
|
||||
assert result.exit_code != 0, "--branch-numbering should be rejected"
|
||||
assert "No such option" in result.output or "no such option" in result.output.lower()
|
||||
@@ -0,0 +1,485 @@
|
||||
"""Tests for check-prerequisites --paths-only skipping branch validation (#2653)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_WINDOWS_POWERSHELL = (shutil.which("powershell.exe") or shutil.which("powershell")) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_bash_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "bash"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, d / "common.sh")
|
||||
shutil.copy(CHECK_PREREQS_SH, d / "check-prerequisites.sh")
|
||||
|
||||
|
||||
def _install_ps_scripts(repo: Path) -> None:
|
||||
d = repo / ".specify" / "scripts" / "powershell"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, d / "common.ps1")
|
||||
shutil.copy(CHECK_PREREQS_PS, d / "check-prerequisites.ps1")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prereq_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_bash_scripts(repo)
|
||||
_install_ps_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
# ── Bash tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must return paths when feature.json pins the feature dir."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "REPO_ROOT" in data
|
||||
assert "BRANCH" in data
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "FEATURE_DIR" in data
|
||||
assert "001-my-feature" in data.get("BRANCH", "")
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
("use_env_var", "specify_feature", "expected_branch"),
|
||||
[
|
||||
(False, None, "001-my-feature"),
|
||||
(True, None, "001-my-feature"),
|
||||
(False, "my-explicit-branch", "my-explicit-branch"),
|
||||
],
|
||||
ids=["feature_json", "env_var", "explicit_feature"],
|
||||
)
|
||||
def test_current_branch_falls_back_to_feature_dir_basename(
|
||||
prereq_repo: Path, use_env_var: bool, specify_feature: str | None, expected_branch: str
|
||||
) -> None:
|
||||
"""With no SPECIFY_FEATURE, BRANCH falls back to the feature directory
|
||||
basename (from feature.json or SPECIFY_FEATURE_DIRECTORY) instead of being
|
||||
emitted empty. If SPECIFY_FEATURE is set, it remains authoritative (#3026)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
env = _clean_env()
|
||||
if specify_feature:
|
||||
env["SPECIFY_FEATURE"] = specify_feature
|
||||
if use_env_var:
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-my-feature"
|
||||
else:
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH"] == expected_branch
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""--paths-only without --json must return text paths from feature.json."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "REPO_ROOT:" in result.stdout
|
||||
assert "FEATURE_DIR:" in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without --paths-only, feature directory validation must still fail on main.
|
||||
|
||||
The error must go to stderr and stdout must stay clean, so a caller that
|
||||
parses stdout as JSON is not handed the error string instead (#3122).
|
||||
"""
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
assert "Feature directory not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
"""--paths-only must not rewrite feature.json even when the env override
|
||||
differs from the pinned value (#3025).
|
||||
|
||||
Path resolution is read-only, so it must never dirty the working tree or
|
||||
overwrite the persisted feature directory.
|
||||
"""
|
||||
pinned = "specs/001-my-feature"
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo, pinned)
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
before = fj.read_text(encoding="utf-8")
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json", "--paths-only"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# The override is honored in the output...
|
||||
data = json.loads(result.stdout)
|
||||
assert "002-other" in data["FEATURE_DIR"]
|
||||
# ...but the pinned file on disk is untouched.
|
||||
assert fj.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_normal_mode_still_persists_feature_json(prereq_repo: Path) -> None:
|
||||
"""Without --paths-only, the env override is still persisted to feature.json,
|
||||
so the --no-persist opt-out does not regress normal write behavior (#3025)."""
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
["bash", str(script), "--json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(fj.read_text(encoding="utf-8"))["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
# ── PowerShell tests ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_non_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must return paths when feature.json pins the feature dir."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "REPO_ROOT" in data
|
||||
assert "BRANCH" in data
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("use_env_var", "specify_feature", "expected_branch"),
|
||||
[
|
||||
(False, None, "001-my-feature"),
|
||||
(True, None, "001-my-feature"),
|
||||
(False, "my-explicit-branch", "my-explicit-branch"),
|
||||
],
|
||||
ids=["feature_json", "env_var", "explicit_feature"],
|
||||
)
|
||||
def test_ps_current_branch_falls_back_to_feature_dir_basename(
|
||||
prereq_repo: Path, use_env_var: bool, specify_feature: str | None, expected_branch: str
|
||||
) -> None:
|
||||
"""With no SPECIFY_FEATURE, BRANCH falls back to the feature directory
|
||||
basename (from feature.json or SPECIFY_FEATURE_DIRECTORY) instead of being
|
||||
emitted empty. If SPECIFY_FEATURE is set, it remains authoritative (#3026)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
env = _clean_env()
|
||||
if specify_feature:
|
||||
env["SPECIFY_FEATURE"] = specify_feature
|
||||
if use_env_var:
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/001-my-feature"
|
||||
else:
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert data["BRANCH"] == expected_branch
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must also work when feature.json and SPECIFY_FEATURE agree."""
|
||||
subprocess.run(
|
||||
["git", "checkout", "-q", "-b", "001-my-feature"],
|
||||
cwd=prereq_repo,
|
||||
check=True,
|
||||
)
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE"] = "001-my-feature"
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "FEATURE_DIR" in data
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, feature directory validation must still fail on main.
|
||||
|
||||
The error must land on stderr only, leaving stdout clean for -Json
|
||||
callers that parse it as JSON (#3122).
|
||||
"""
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "Feature directory not found" in result.stderr
|
||||
assert "Feature directory not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_missing_plan_error_goes_to_stderr(prereq_repo: Path) -> None:
|
||||
"""A missing plan.md must report on stderr, not stdout (#3122)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "plan.md not found" in result.stderr
|
||||
assert "plan.md not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None:
|
||||
"""With -RequireTasks, a missing tasks.md must report on stderr only (#3122)."""
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo)
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-RequireTasks"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=_clean_env(),
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "tasks.md not found" in result.stderr
|
||||
assert "tasks.md not found" not in result.stdout
|
||||
assert result.stdout.strip() == ""
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
"""-PathsOnly must not rewrite feature.json even when the env override
|
||||
differs from the pinned value (#3025)."""
|
||||
pinned = "specs/001-my-feature"
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True, exist_ok=True)
|
||||
_write_feature_json(prereq_repo, pinned)
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
before = fj.read_text(encoding="utf-8")
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json", "-PathsOnly"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = json.loads(result.stdout)
|
||||
assert "002-other" in data["FEATURE_DIR"]
|
||||
assert fj.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_ps_normal_mode_still_persists_feature_json(prereq_repo: Path) -> None:
|
||||
"""Without -PathsOnly, the env override is still persisted to feature.json,
|
||||
so the -NoPersist opt-out does not regress normal write behavior (#3025).
|
||||
|
||||
Symmetric to the bash test_normal_mode_still_persists_feature_json guard:
|
||||
asserts the default path still persists and that -NoPersist is not passed
|
||||
unconditionally.
|
||||
"""
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True, exist_ok=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True, exist_ok=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
fj = prereq_repo / ".specify" / "feature.json"
|
||||
|
||||
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
result = subprocess.run(
|
||||
[exe, "-NoProfile", "-File", str(script), "-Json"],
|
||||
cwd=prereq_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(fj.read_text(encoding="utf-8"))["feature_directory"] == "specs/002-other"
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Parity tests for the Python check-prerequisites PoC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
CHECK_PREREQS_SH = PROJECT_ROOT / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
CHECK_PREREQS_PS = PROJECT_ROOT / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
COMMON_PY = PROJECT_ROOT / "scripts" / "python" / "common.py"
|
||||
CHECK_PREREQS_PY = PROJECT_ROOT / "scripts" / "python" / "check_prerequisites.py"
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_WINDOWS_POWERSHELL = (
|
||||
shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
) if os.name == "nt" else None
|
||||
|
||||
|
||||
def _install_scripts(repo: Path) -> None:
|
||||
bash_dir = repo / ".specify" / "scripts" / "bash"
|
||||
bash_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_SH, bash_dir / "common.sh")
|
||||
shutil.copy(CHECK_PREREQS_SH, bash_dir / "check-prerequisites.sh")
|
||||
|
||||
ps_dir = repo / ".specify" / "scripts" / "powershell"
|
||||
ps_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PS, ps_dir / "common.ps1")
|
||||
shutil.copy(CHECK_PREREQS_PS, ps_dir / "check-prerequisites.ps1")
|
||||
|
||||
py_dir = repo / ".specify" / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
|
||||
def _write_feature_json(
|
||||
repo: Path, feature_directory: str = "specs/001-my-feature"
|
||||
) -> None:
|
||||
(repo / ".specify" / "feature.json").write_text(
|
||||
json.dumps({"feature_directory": feature_directory}, separators=(",", ":"))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _git_init(repo: Path) -> None:
|
||||
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prereq_repo(tmp_path: Path) -> Path:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
return repo
|
||||
|
||||
|
||||
def _py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _repo_copy_py_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / "scripts" / "python" / "check_prerequisites.py"
|
||||
return [sys.executable, str(script), *args]
|
||||
|
||||
|
||||
def _bash_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
|
||||
return ["bash", str(script), *args]
|
||||
|
||||
|
||||
def _ps_cmd(repo: Path, *args: str) -> list[str]:
|
||||
script = repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
|
||||
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
|
||||
return [exe, "-NoProfile", "-File", str(script), *args]
|
||||
|
||||
|
||||
def _run(
|
||||
cmd: list[str], repo: Path, env: dict[str, str] | None = None
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env or _clean_env(),
|
||||
)
|
||||
|
||||
|
||||
def _json_stdout(result: subprocess.CompletedProcess[str]) -> object:
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _normalize_status_text(text: str) -> str:
|
||||
return (
|
||||
text.replace(" ✓ ", " [OK] ")
|
||||
.replace(" ✗ ", " [FAIL] ")
|
||||
.replace("\r\n", "\n")
|
||||
)
|
||||
|
||||
|
||||
def _normalize_help_text(text: str) -> str:
|
||||
normalized = text.replace("\r\n", "\n").replace(
|
||||
"check-prerequisites.sh", "check_prerequisites.py"
|
||||
)
|
||||
return "\n".join("" if not line.strip() else line for line in normalized.split("\n"))
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
("--json",),
|
||||
("--json", "--include-tasks"),
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("--json", "--paths-only"),
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_bash(prereq_repo: Path, args: tuple[str, ...]) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, *args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *args), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(bash)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_text_output_matches_bash(prereq_repo: Path) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "contracts").mkdir()
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
bash = _run(_bash_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--include-tasks"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_status_text(py.stdout) == _normalize_status_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_help_output_matches_bash(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--help"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert py.stderr == bash.stderr == ""
|
||||
assert _normalize_help_text(py.stdout) == _normalize_help_text(bash.stdout)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_unknown_option_matches_bash_error_shape(prereq_repo: Path) -> None:
|
||||
bash = _run(_bash_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, "--bogus"), prereq_repo)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.stderr == bash.stderr
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
@pytest.mark.parametrize(
|
||||
("py_args", "ps_args"),
|
||||
[
|
||||
(("--json",), ("-Json",)),
|
||||
(("--json", "--include-tasks"), ("-Json", "-IncludeTasks")),
|
||||
(
|
||||
("--json", "--require-tasks", "--include-tasks"),
|
||||
("-Json", "-RequireTasks", "-IncludeTasks"),
|
||||
),
|
||||
(("--json", "--paths-only"), ("-Json", "-PathsOnly")),
|
||||
],
|
||||
ids=[
|
||||
"json",
|
||||
"json_include_tasks",
|
||||
"json_require_tasks_include_tasks",
|
||||
"json_paths_only",
|
||||
],
|
||||
)
|
||||
def test_python_json_output_matches_powershell(
|
||||
prereq_repo: Path, py_args: tuple[str, ...], ps_args: tuple[str, ...]
|
||||
) -> None:
|
||||
feat = prereq_repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
(feat / "tasks.md").write_text("# tasks\n", encoding="utf-8")
|
||||
(feat / "research.md").write_text("# research\n", encoding="utf-8")
|
||||
(feat / "data-model.md").write_text("# model\n", encoding="utf-8")
|
||||
(feat / "quickstart.md").write_text("# quickstart\n", encoding="utf-8")
|
||||
(feat / "contracts" / "v1").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
ps = _run(_ps_cmd(prereq_repo, *ps_args), prereq_repo)
|
||||
py = _run(_py_cmd(prereq_repo, *py_args), prereq_repo)
|
||||
|
||||
assert py.returncode == ps.returncode == 0
|
||||
assert py.stderr == ps.stderr == ""
|
||||
assert _json_stdout(py) == _json_stdout(ps)
|
||||
|
||||
|
||||
def test_python_repo_copy_script_file_fallback_finds_repo_root(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
outside = tmp_path / "outside"
|
||||
repo.mkdir()
|
||||
outside.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_write_feature_json(repo)
|
||||
(repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
|
||||
py_dir = repo / "scripts" / "python"
|
||||
py_dir.mkdir(parents=True)
|
||||
shutil.copy(COMMON_PY, py_dir / "common.py")
|
||||
shutil.copy(CHECK_PREREQS_PY, py_dir / "check_prerequisites.py")
|
||||
|
||||
py = _run(_repo_copy_py_cmd(repo, "--json", "--paths-only"), outside)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert Path(_json_stdout(py)["REPO_ROOT"]) == repo
|
||||
|
||||
|
||||
def test_python_paths_only_does_not_persist_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
(prereq_repo / "specs" / "002-other").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
feature_json = prereq_repo / ".specify" / "feature.json"
|
||||
before = feature_json.read_text(encoding="utf-8")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert "002-other" in _json_stdout(py)["FEATURE_DIR"]
|
||||
assert feature_json.read_text(encoding="utf-8") == before
|
||||
|
||||
|
||||
def test_python_normal_mode_persists_feature_json(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
feat = prereq_repo / "specs" / "002-other"
|
||||
feat.mkdir(parents=True)
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
_write_feature_json(prereq_repo, "specs/001-my-feature")
|
||||
env = _clean_env()
|
||||
env["SPECIFY_FEATURE_DIRECTORY"] = "specs/002-other"
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json"), prereq_repo, env=env)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
data = json.loads(
|
||||
(prereq_repo / ".specify" / "feature.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["feature_directory"] == "specs/002-other"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "expected"),
|
||||
[
|
||||
(("--json",), "Feature directory not found"),
|
||||
(("--json",), "plan.md not found"),
|
||||
(("--json", "--require-tasks"), "tasks.md not found"),
|
||||
],
|
||||
ids=["missing_feature_context", "missing_plan", "missing_tasks"],
|
||||
)
|
||||
def test_python_negative_errors_are_stderr_only(
|
||||
tmp_path: Path, args: tuple[str, ...], expected: str
|
||||
) -> None:
|
||||
repo = tmp_path / "proj"
|
||||
repo.mkdir()
|
||||
_git_init(repo)
|
||||
(repo / ".specify").mkdir()
|
||||
_install_scripts(repo)
|
||||
|
||||
if expected in {"plan.md not found", "tasks.md not found"}:
|
||||
feat = repo / "specs" / "001-my-feature"
|
||||
feat.mkdir(parents=True)
|
||||
_write_feature_json(repo)
|
||||
if expected == "tasks.md not found":
|
||||
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
py = _run(_py_cmd(repo, *args), repo)
|
||||
|
||||
assert py.returncode != 0
|
||||
assert expected in py.stderr
|
||||
assert expected not in py.stdout
|
||||
assert py.stdout.strip() == ""
|
||||
|
||||
|
||||
def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) -> None:
|
||||
(prereq_repo / "specs" / "001-my-feature").mkdir(parents=True)
|
||||
_write_feature_json(prereq_repo)
|
||||
|
||||
py = _run(_py_cmd(prereq_repo, "--json", "--paths-only"), prereq_repo)
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Tests for check_tool() — Claude Code CLI detection across install methods.
|
||||
|
||||
Covers issue https://github.com/github/spec-kit/issues/550:
|
||||
`specify check` reports "Claude Code CLI (not found)" even when claude is
|
||||
installed via npm-local (the default `claude` installer path).
|
||||
"""
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app, check_tool
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestCheckToolClaude:
|
||||
"""Claude CLI detection must work for all install methods."""
|
||||
|
||||
def test_detected_via_migrate_installer_path(self, tmp_path):
|
||||
"""claude migrate-installer puts binary at ~/.claude/local/claude."""
|
||||
fake_claude = tmp_path / "claude"
|
||||
fake_claude.touch()
|
||||
|
||||
# Ensure npm-local path is missing so we only exercise migrate-installer path
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_claude), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_claude), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert check_tool("claude") is True
|
||||
|
||||
def test_detected_via_npm_local_path(self, tmp_path):
|
||||
"""npm-local install puts binary at ~/.claude/local/node_modules/.bin/claude."""
|
||||
fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
|
||||
fake_npm_claude.parent.mkdir(parents=True)
|
||||
fake_npm_claude.touch()
|
||||
|
||||
# Neither the migrate-installer path nor PATH has claude
|
||||
fake_migrate = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_migrate), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_migrate), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert check_tool("claude") is True
|
||||
|
||||
def test_detected_via_path(self, tmp_path):
|
||||
"""claude on PATH (global npm install) should still work."""
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value="/usr/local/bin/claude"):
|
||||
assert check_tool("claude") is True
|
||||
|
||||
def test_not_found_when_nowhere(self, tmp_path):
|
||||
"""Should return False when claude is genuinely not installed."""
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_missing), \
|
||||
patch("shutil.which", return_value=None):
|
||||
assert check_tool("claude") is False
|
||||
|
||||
def test_tracker_updated_on_npm_local_detection(self, tmp_path):
|
||||
"""StepTracker should be marked 'available' for npm-local installs."""
|
||||
fake_npm_claude = tmp_path / "node_modules" / ".bin" / "claude"
|
||||
fake_npm_claude.parent.mkdir(parents=True)
|
||||
fake_npm_claude.touch()
|
||||
|
||||
fake_missing = tmp_path / "nonexistent" / "claude"
|
||||
tracker = MagicMock()
|
||||
|
||||
with patch("specify_cli.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli._utils.CLAUDE_LOCAL_PATH", fake_missing), \
|
||||
patch("specify_cli.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("specify_cli._utils.CLAUDE_NPM_LOCAL_PATH", fake_npm_claude), \
|
||||
patch("shutil.which", return_value=None):
|
||||
result = check_tool("claude", tracker=tracker)
|
||||
|
||||
assert result is True
|
||||
tracker.complete.assert_called_once_with("claude", "available")
|
||||
|
||||
|
||||
class TestCheckToolOther:
|
||||
"""Non-Claude tools should be unaffected by the fix."""
|
||||
|
||||
def test_git_detected_via_path(self):
|
||||
with patch("shutil.which", return_value="/usr/bin/git"):
|
||||
assert check_tool("git") is True
|
||||
|
||||
def test_missing_tool(self):
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert check_tool("nonexistent-tool") is False
|
||||
|
||||
def test_kiro_fallback(self):
|
||||
"""kiro-cli detection should try both kiro-cli and kiro."""
|
||||
def fake_which(name):
|
||||
return "/usr/bin/kiro" if name == "kiro" else None
|
||||
|
||||
with patch("shutil.which", side_effect=fake_which):
|
||||
assert check_tool("kiro-cli") is True
|
||||
|
||||
def test_rovodev_uses_acli_executable(self):
|
||||
"""rovodev should resolve through the shared acli executable."""
|
||||
|
||||
def fake_which(name):
|
||||
return "/usr/bin/acli" if name == "acli" else None
|
||||
|
||||
with patch("shutil.which", side_effect=fake_which):
|
||||
assert check_tool("rovodev") is True
|
||||
|
||||
|
||||
class TestCheckTip:
|
||||
"""`specify check` should point users to the existing version check."""
|
||||
|
||||
def test_check_shows_self_check_tip(self):
|
||||
with patch("specify_cli.check_tool", return_value=True):
|
||||
result = runner.invoke(app, ["check"])
|
||||
|
||||
output = strip_ansi(result.output)
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
"Tip: Run 'specify self check' to verify you have the latest CLI version"
|
||||
in output
|
||||
)
|
||||
|
||||
def test_check_tip_does_not_fetch_latest_release(self):
|
||||
with (
|
||||
patch("specify_cli.check_tool", return_value=True),
|
||||
patch(
|
||||
"specify_cli._version._fetch_latest_release_tag",
|
||||
side_effect=AssertionError("latest release lookup should not run"),
|
||||
) as fetch_latest,
|
||||
):
|
||||
result = runner.invoke(app, ["check"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
fetch_latest.assert_not_called()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for CLI version reporting."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class TestVersionFlag:
|
||||
"""Test --version / -V flag on the root command."""
|
||||
|
||||
def test_version_long_flag(self):
|
||||
"""specify --version prints version and exits 0."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 1.2.3" in result.output
|
||||
|
||||
def test_version_short_flag(self):
|
||||
"""specify -V prints version and exits 0."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["-V"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 1.2.3" in result.output
|
||||
|
||||
def test_version_flag_takes_precedence_over_subcommand(self):
|
||||
"""--version should work even when a subcommand follows."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="0.7.2"):
|
||||
result = runner.invoke(app, ["--version", "init"])
|
||||
assert result.exit_code == 0
|
||||
assert "specify 0.7.2" in result.output
|
||||
|
||||
|
||||
class TestVersionCommand:
|
||||
"""Test the `specify version` subcommand."""
|
||||
|
||||
def test_version_features_text(self):
|
||||
"""specify version --features prints local capability flags."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Spec Kit CLI: 1.2.3" in result.output
|
||||
assert "Features:" in result.output
|
||||
assert "- controlled multi install integrations: yes" in result.output
|
||||
assert "- integration use command: yes" in result.output
|
||||
assert "- self check command: yes" in result.output
|
||||
|
||||
def test_version_features_json(self):
|
||||
"""specify version --features --json prints machine-readable capabilities."""
|
||||
with patch("specify_cli.get_speckit_version", return_value="1.2.3"):
|
||||
result = runner.invoke(app, ["version", "--features", "--json"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload == {
|
||||
"version": "1.2.3",
|
||||
"features": {
|
||||
"controlled_multi_install_integrations": True,
|
||||
"integration_use_command": True,
|
||||
"multi_install_safe_registry_metadata": True,
|
||||
"integration_upgrade_command": True,
|
||||
"self_check_command": True,
|
||||
"workflow_catalog": True,
|
||||
"bundled_templates": True,
|
||||
},
|
||||
}
|
||||
|
||||
def test_version_json_requires_features(self):
|
||||
"""specify version --json is rejected until a JSON surface exists."""
|
||||
result = runner.invoke(app, ["version", "--json"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "--json requires --features" in result.output
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Command templates with a py: script line must render for --script py.
|
||||
|
||||
Covers #3283: ``py:`` lines in the ``scripts:`` frontmatter of
|
||||
``templates/commands/*.md`` reference Python scripts that exist in the repo,
|
||||
and ``process_template`` turns them into a valid Python invocation
|
||||
(interpreter-prefixed, path rewritten to the ``.specify`` tree).
|
||||
|
||||
``plan.md`` and ``tasks.md`` gain their ``py:`` lines together with
|
||||
``setup_plan.py``/``setup_tasks.py`` in the core-scripts port (#3280); the
|
||||
existence check below enforces that ordering.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent
|
||||
TEMPLATES_DIR = REPO_ROOT / "templates" / "commands"
|
||||
|
||||
_PY_LINE = re.compile(r"^\s*py: (scripts/python/\S+\.py)", re.MULTILINE)
|
||||
|
||||
|
||||
def _py_script(name: str) -> str | None:
|
||||
match = _PY_LINE.search((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
PY_TEMPLATES = sorted(
|
||||
p.name for p in TEMPLATES_DIR.glob("*.md") if _py_script(p.name)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pin_interpreter(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.shutil.which",
|
||||
lambda name: "/usr/bin/python3" if name == "python3" else None,
|
||||
)
|
||||
# On Windows, ``resolve_python_interpreter`` guards the ``which`` result
|
||||
# with a real ``_interpreter_runs`` subprocess probe (#3304). The mocked
|
||||
# ``/usr/bin/python3`` path does not exist on a Windows runner, so the
|
||||
# probe would fail and the resolver would fall back to ``sys.executable``
|
||||
# (a ``...python.exe`` path), breaking the ``python3``-anchored assertion.
|
||||
# Pin the probe to True so the interpreter token stays ``python3`` on all
|
||||
# platforms.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.integrations.base.IntegrationBase._interpreter_runs",
|
||||
staticmethod(lambda path: True),
|
||||
)
|
||||
|
||||
|
||||
def test_py_templates_discovered():
|
||||
# Guard: the glob must find the known py-scripted templates, otherwise
|
||||
# the parametrized tests below would silently pass on an empty set.
|
||||
assert "implement.md" in PY_TEMPLATES
|
||||
assert "clarify.md" in PY_TEMPLATES
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_referenced_python_script_exists(name: str):
|
||||
# A py: line must never point at a script the repo does not ship —
|
||||
# rendering would produce a broken invocation at runtime.
|
||||
script = _py_script(name)
|
||||
assert (REPO_ROOT / script).is_file(), f"{name} references missing {script}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_template_renders_python_invocation(name: str):
|
||||
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
|
||||
result = IntegrationBase.process_template(content, "agent", "py")
|
||||
assert "{SCRIPT}" not in result
|
||||
assert re.search(
|
||||
r"python3 \.specify/scripts/python/\w+\.py(?: --[\w-]+)*", result
|
||||
), f"{name} did not render a Python invocation"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", PY_TEMPLATES)
|
||||
def test_sh_rendering_unchanged(name: str):
|
||||
# Negative: adding py: lines must not leak into sh rendering.
|
||||
content = (TEMPLATES_DIR / name).read_text(encoding="utf-8")
|
||||
result = IntegrationBase.process_template(content, "agent", "sh")
|
||||
assert "{SCRIPT}" not in result
|
||||
assert "scripts/python" not in result
|
||||
|
||||
|
||||
def test_install_shared_infra_copies_python_scripts(tmp_path):
|
||||
# --script py must install scripts/python/ into .specify/scripts/python/
|
||||
# so the rendered invocations point at files that exist.
|
||||
from rich.console import Console
|
||||
|
||||
from specify_cli.shared_infra import install_shared_infra
|
||||
|
||||
install_shared_infra(
|
||||
tmp_path,
|
||||
"py",
|
||||
version="0.0.0",
|
||||
core_pack=None,
|
||||
repo_root=REPO_ROOT,
|
||||
console=Console(quiet=True),
|
||||
force=False,
|
||||
)
|
||||
dest = tmp_path / ".specify" / "scripts" / "python"
|
||||
assert (dest / "check_prerequisites.py").is_file()
|
||||
assert not (tmp_path / ".specify" / "scripts" / "powershell").exists()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for the commands/ package structure."""
|
||||
import importlib
|
||||
|
||||
|
||||
def test_commands_package_importable():
|
||||
mod = importlib.import_module("specify_cli.commands")
|
||||
assert mod is not None
|
||||
|
||||
|
||||
def test_commands_init_importable():
|
||||
mod = importlib.import_module("specify_cli.commands.init")
|
||||
assert hasattr(mod, "register")
|
||||
assert callable(mod.register)
|
||||
|
||||
|
||||
def test_agent_config_importable():
|
||||
from specify_cli._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
)
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert DEFAULT_INIT_INTEGRATION == "copilot"
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_script_type_choices_includes_python():
|
||||
from specify_cli._agent_config import SCRIPT_TYPE_CHOICES
|
||||
assert SCRIPT_TYPE_CHOICES.get("py") == "Python"
|
||||
# The three supported variants are sh, ps, and py.
|
||||
assert {"sh", "ps", "py"} <= set(SCRIPT_TYPE_CHOICES)
|
||||
|
||||
|
||||
def test_workflow_init_valid_script_types_includes_python():
|
||||
from specify_cli.workflows.steps.init import VALID_SCRIPT_TYPES
|
||||
assert "py" in VALID_SCRIPT_TYPES
|
||||
# Negative: an unknown variant is not accepted.
|
||||
assert "rb" not in VALID_SCRIPT_TYPES
|
||||
|
||||
|
||||
def test_agent_config_re_exported_from_init():
|
||||
from specify_cli import AGENT_CONFIG, SCRIPT_TYPE_CHOICES
|
||||
assert isinstance(AGENT_CONFIG, dict)
|
||||
assert "sh" in SCRIPT_TYPE_CHOICES
|
||||
|
||||
|
||||
def test_init_command_registered():
|
||||
from specify_cli import app
|
||||
callback_names = [
|
||||
cmd.callback.__name__ for cmd in app.registered_commands if cmd.callback
|
||||
]
|
||||
assert "init" in callback_names
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Regression guard: console symbols must remain importable from specify_cli."""
|
||||
from specify_cli import (
|
||||
console,
|
||||
StepTracker,
|
||||
select_with_arrows,
|
||||
)
|
||||
|
||||
|
||||
def test_console_symbols_importable():
|
||||
from rich.console import Console
|
||||
assert isinstance(console, Console)
|
||||
|
||||
|
||||
def test_console_symbols_available_from_star_import():
|
||||
namespace = {}
|
||||
exec("from specify_cli import *", namespace)
|
||||
|
||||
for symbol in (
|
||||
"console",
|
||||
"StepTracker",
|
||||
"get_key",
|
||||
"select_with_arrows",
|
||||
"BannerGroup",
|
||||
"show_banner",
|
||||
"BANNER",
|
||||
"TAGLINE",
|
||||
):
|
||||
assert symbol in namespace
|
||||
|
||||
|
||||
def test_step_tracker_instantiable():
|
||||
tracker = StepTracker("test")
|
||||
tracker.add("step1", "Step One")
|
||||
tracker.complete("step1", "done")
|
||||
assert tracker.steps[0]["status"] == "done"
|
||||
|
||||
|
||||
def test_select_with_arrows_raises_on_empty_options():
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="at least one option"):
|
||||
select_with_arrows({})
|
||||
@@ -0,0 +1,497 @@
|
||||
import pytest
|
||||
import yaml
|
||||
from specify_cli.extensions import HookExecutor, ExtensionManifest
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = tmp_path / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
return proj_dir
|
||||
|
||||
class TestExtensionRegistration:
|
||||
"""Tests for the 'installed' list management in HookExecutor."""
|
||||
|
||||
def test_register_extension_new(self, project_dir):
|
||||
"""Standard registration: Adding an extension should add it to the list."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "installed" in config
|
||||
assert config["installed"] == ["test-ext"]
|
||||
|
||||
def test_register_extension_sorting(self, project_dir):
|
||||
"""Order Stability: Extensions should be stored in alphabetical order."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("zebra-ext")
|
||||
executor.register_extension("apple-ext")
|
||||
executor.register_extension("middle-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["apple-ext", "middle-ext", "zebra-ext"]
|
||||
|
||||
def test_register_extension_idempotency(self, project_dir):
|
||||
"""Idempotency: Adding the same extension twice should not result in duplicates."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("test-ext")
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["test-ext"]
|
||||
assert len(config["installed"]) == 1
|
||||
|
||||
def test_unregister_extension(self, project_dir):
|
||||
"""Standard unregistration: Removing an extension should prune it from the list."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("ext-1")
|
||||
executor.register_extension("ext-2")
|
||||
|
||||
executor.unregister_extension("ext-1")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["ext-2"]
|
||||
|
||||
def test_unregister_extension_not_present(self, project_dir):
|
||||
"""Safe Removal: Unregistering a non-existent extension should do nothing."""
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_extension("ext-1")
|
||||
|
||||
# Should not raise or change the list
|
||||
executor.unregister_extension("ext-nonexistent")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["ext-1"]
|
||||
|
||||
def test_register_hooks_triggers_registration(self, project_dir, tmp_path):
|
||||
"""Full Workflow: register_hooks should automatically register the extension."""
|
||||
# Create a mock manifest
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hook-ext",
|
||||
"name": "Hook Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.hook-ext.run"}
|
||||
}
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# This should call register_extension internally
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" in config["installed"]
|
||||
|
||||
def test_missing_installed_key_initialization(self, project_dir):
|
||||
"""Graceful Initialization: If 'installed' key is missing, it should be created."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually create a config without 'installed'
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}}))
|
||||
|
||||
# This should detect the missing key and initialize it
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "installed" in config
|
||||
assert config["installed"] == ["new-ext"]
|
||||
|
||||
def test_unregister_hooks_full_workflow(self, project_dir, tmp_path):
|
||||
"""Full Workflow: unregister_hooks should remove hooks and prune installed list."""
|
||||
# Create a manifest with hooks
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "hook-ext",
|
||||
"name": "Hook Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.hook-ext.run"}
|
||||
}
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Register hooks first
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" in config["installed"]
|
||||
assert "after_tasks" in config["hooks"]
|
||||
|
||||
# Now unregister hooks
|
||||
executor.unregister_hooks("hook-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "hook-ext" not in config["installed"]
|
||||
# unregister_hooks() removes the empty hook array entirely, so the key is absent
|
||||
assert "after_tasks" not in config["hooks"]
|
||||
|
||||
def test_unregister_hooks_no_hooks_key(self, project_dir):
|
||||
"""Resilience: unregister_hooks should work even if config has no 'hooks' key."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Register extension without hooks
|
||||
executor.register_extension("ext-no-hooks")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-no-hooks" in config["installed"]
|
||||
|
||||
# Unregister should not crash even if no hooks key exists
|
||||
executor.unregister_hooks("ext-no-hooks")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-no-hooks" not in config["installed"]
|
||||
|
||||
def test_unregister_hooks_corrupted_config(self, project_dir):
|
||||
"""Resilience: unregister_hooks should gracefully handle corrupted config."""
|
||||
# Create a corrupted config (root is a list)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump(["corrupted", "list"]))
|
||||
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Should not raise even with corrupted config
|
||||
executor.unregister_hooks("non-existent")
|
||||
|
||||
# Config should remain as-is or be handled gracefully
|
||||
config = executor.get_project_config()
|
||||
# If it's corrupted, it's returned as-is or handled by defensive logic
|
||||
assert config is not None
|
||||
|
||||
def test_unregister_hooks_with_multiple_extensions(self, project_dir, tmp_path):
|
||||
"""Multiple Extensions: unregister_hooks should only remove target extension's hooks."""
|
||||
# Create two manifests
|
||||
manifest_data_1 = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-1",
|
||||
"name": "Ext 1",
|
||||
"version": "1.0.0",
|
||||
"description": "Test 1",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.ext-1.run"}
|
||||
}
|
||||
}
|
||||
manifest_data_2 = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "ext-2",
|
||||
"name": "Ext 2",
|
||||
"version": "1.0.0",
|
||||
"description": "Test 2",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "speckit.ext-2.run"}
|
||||
}
|
||||
}
|
||||
|
||||
manifest_path_1 = tmp_path / "extension1.yml"
|
||||
manifest_path_2 = tmp_path / "extension2.yml"
|
||||
with open(manifest_path_1, "w") as f:
|
||||
yaml.dump(manifest_data_1, f)
|
||||
with open(manifest_path_2, "w") as f:
|
||||
yaml.dump(manifest_data_2, f)
|
||||
|
||||
manifest1 = ExtensionManifest(manifest_path_1)
|
||||
manifest2 = ExtensionManifest(manifest_path_2)
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Register both extensions
|
||||
executor.register_hooks(manifest1)
|
||||
executor.register_hooks(manifest2)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-1" in config["installed"]
|
||||
assert "ext-2" in config["installed"]
|
||||
assert len(config["hooks"]["after_tasks"]) == 2
|
||||
|
||||
# Unregister first extension
|
||||
executor.unregister_hooks("ext-1")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "ext-1" not in config["installed"]
|
||||
assert "ext-2" in config["installed"]
|
||||
# ext-2's hook should still be there
|
||||
assert len(config["hooks"]["after_tasks"]) == 1
|
||||
assert config["hooks"]["after_tasks"][0].get("extension") == "ext-2"
|
||||
|
||||
def test_register_hooks_no_hooks_still_registers(self, project_dir, tmp_path):
|
||||
"""Commands-only manifest: register_hooks() must still update installed even with no hooks."""
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "commands-only-ext",
|
||||
"name": "Commands Only",
|
||||
"version": "1.0.0",
|
||||
"description": "No hooks, only commands",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": [{"name": "speckit.commands-only-ext.run", "file": "commands/run.md"}]},
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
executor = HookExecutor(project_dir)
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "commands-only-ext" in config["installed"]
|
||||
|
||||
def test_register_extension_mixed_type_installed(self, project_dir):
|
||||
"""Regression: installed list with non-string entries must not crash on sort."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually write a corrupted installed list with non-string entries
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"installed": [1, True, "existing-ext"]}))
|
||||
|
||||
# Should not raise TypeError on sort
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
# Non-string entries are dropped; valid strings are preserved
|
||||
assert "existing-ext" in config["installed"]
|
||||
assert "new-ext" in config["installed"]
|
||||
assert 1 not in config["installed"]
|
||||
assert True not in config["installed"]
|
||||
|
||||
def test_unregister_hooks_null_hook_values(self, project_dir):
|
||||
"""Regression: hooks: {after_tasks: null} must not crash in unregister_hooks()."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually write a config with null hook event value
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["broken-ext"],
|
||||
"hooks": {"after_tasks": None}
|
||||
}))
|
||||
|
||||
# Should not raise TypeError when iterating None
|
||||
executor.unregister_hooks("broken-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "broken-ext" not in config["installed"]
|
||||
|
||||
def test_register_hooks_corrupted_hook_values(self, project_dir, tmp_path):
|
||||
"""Regression: register_hooks() must handle non-list hook event values in config."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Manually write a config with null hook event value
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["some-ext"],
|
||||
"hooks": {"after_tasks": None}
|
||||
}))
|
||||
|
||||
# Create a manifest with a hook for the same event
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "new-ext",
|
||||
"name": "New Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {"after_tasks": {"command": "speckit.new-ext.run"}}
|
||||
}
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
with open(manifest_path, "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
# Should not raise TypeError when trying to append to None
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "new-ext" in config["installed"]
|
||||
assert isinstance(config["hooks"]["after_tasks"], list)
|
||||
assert any(h["extension"] == "new-ext" for h in config["hooks"]["after_tasks"])
|
||||
|
||||
def test_register_extension_already_present_in_corrupted_list(self, project_dir):
|
||||
"""Regression: if extension is already present but list has non-strings, it must still be sanitized."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
# Extension is present, but list has garbage
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"installed": [1, "test-ext", True]}))
|
||||
|
||||
# This should trigger sanitization and save, even though "test-ext" is already there
|
||||
executor.register_extension("test-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["test-ext"]
|
||||
# Verify it was actually saved to disk
|
||||
raw_config = yaml.safe_load(config_path.read_text())
|
||||
assert raw_config["installed"] == ["test-ext"]
|
||||
|
||||
def test_register_extension_with_dict_entry(self, project_dir):
|
||||
"""Review Feedback: register_extension should support and preserve dict entries."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
# Setup config with a pinned extension (dict)
|
||||
pinned_ext = {"id": "pinned-ext", "version": "1.0.0"}
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": [pinned_ext, "string-ext"]
|
||||
}))
|
||||
|
||||
# Register a new extension
|
||||
executor.register_extension("new-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
# Should contain all three, sorted by id: new-ext, pinned-ext, string-ext
|
||||
assert config["installed"] == ["new-ext", pinned_ext, "string-ext"]
|
||||
|
||||
def test_unregister_extension_with_dict_entry(self, project_dir):
|
||||
"""Review Feedback: unregister_extension should support removing matching dict entries."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
pinned_ext = {"id": "to-remove", "version": "1.0.0"}
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": [pinned_ext, "other-ext"]
|
||||
}))
|
||||
|
||||
# Unregister by ID
|
||||
executor.unregister_extension("to-remove")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == ["other-ext"]
|
||||
|
||||
def test_unregister_extension_corrupted_installed(self, project_dir):
|
||||
"""Hardening: unregister_extension should handle non-list installed key."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": "not-a-list"
|
||||
}))
|
||||
|
||||
# Should not crash and should normalize to []
|
||||
executor.unregister_extension("any-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert config["installed"] == []
|
||||
def test_register_hooks_mixed_type_hook_list(self, project_dir, tmp_path):
|
||||
"""Regression: register_hooks() must sanitize hook event lists by dropping non-dicts."""
|
||||
executor = HookExecutor(project_dir)
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["some-ext"],
|
||||
"hooks": {"after_tasks": [1, "corrupted", {"extension": "other", "command": "cmd"}]}
|
||||
}))
|
||||
|
||||
manifest_path = tmp_path / "extension.yml"
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": "new-ext",
|
||||
"name": "New Ext",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
"author": "Test author"
|
||||
},
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"commands": []
|
||||
},
|
||||
"provides": {"commands": []},
|
||||
"hooks": {
|
||||
"after_tasks": {"command": "new-cmd"}
|
||||
}
|
||||
}
|
||||
manifest_path.write_text(yaml.dump(manifest_data))
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
|
||||
executor.register_hooks(manifest)
|
||||
|
||||
config = executor.get_project_config()
|
||||
hooks = config["hooks"]["after_tasks"]
|
||||
|
||||
# Should have 2 valid dict hooks, and 0 non-dict items
|
||||
assert len(hooks) == 2
|
||||
assert all(isinstance(h, dict) for h in hooks)
|
||||
assert any(h.get("extension") == "other" for h in hooks)
|
||||
assert any(h.get("extension") == "new-ext" for h in hooks)
|
||||
|
||||
def test_unregister_extension_scalar_root(self, project_dir):
|
||||
"""Hardening: unregister_extension should handle scalar root config."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
config_path.write_text(yaml.dump(123))
|
||||
|
||||
# Should not crash and should normalize to {}
|
||||
executor.unregister_extension("any-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert isinstance(config, dict)
|
||||
assert config["installed"] == []
|
||||
|
||||
def test_unregister_hooks_scalar_hook_values(self, project_dir):
|
||||
"""Regression: unregister_hooks() must handle scalar hook event values."""
|
||||
executor = HookExecutor(project_dir)
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["some-ext"],
|
||||
"hooks": {"after_tasks": 123}
|
||||
}))
|
||||
|
||||
# Should not raise TypeError when iterating
|
||||
executor.unregister_hooks("some-ext")
|
||||
|
||||
config = executor.get_project_config()
|
||||
assert "some-ext" not in config["installed"]
|
||||
assert "after_tasks" not in config["hooks"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
from specify_cli.extensions import ExtensionManager, ExtensionRegistry, ExtensionCatalog
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path):
|
||||
"""Create a mock spec-kit project directory."""
|
||||
proj_dir = tmp_path / "project"
|
||||
proj_dir.mkdir()
|
||||
(proj_dir / ".specify").mkdir()
|
||||
# Create required files for a project
|
||||
(proj_dir / ".specify" / "config.toml").write_text("ai = 'claude'")
|
||||
return proj_dir
|
||||
|
||||
def test_extension_update_corrupted_config_root(project_dir, monkeypatch):
|
||||
"""Regression: extension update must handle corrupted extensions.yml (root is scalar)."""
|
||||
# chdir into project_dir so _require_specify_project() succeeds
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
# Corrupt extensions.yml
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump(123))
|
||||
|
||||
# Mock ExtensionManager to return an installed extension for resolution
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
|
||||
# Mock download_extension to avoid network calls; use tmp_path so the test is hermetic
|
||||
# and returns a Path so zip_path.exists() / zip_path.unlink() work without AttributeError
|
||||
mock_zip = project_dir / "mock.zip"
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
||||
|
||||
# Mock confirmation to true
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
# Run update
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
||||
assert result.exit_code == 1
|
||||
assert "AttributeError" not in result.output
|
||||
assert not isinstance(result.exception, AttributeError)
|
||||
|
||||
def test_extension_update_corrupted_hooks_value(project_dir, monkeypatch):
|
||||
"""Regression: extension update must handle non-dict 'hooks' in extensions.yml."""
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({
|
||||
"installed": ["test-ext"],
|
||||
"hooks": ["not", "a", "dict"]
|
||||
}))
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
# Use tmp_path-scoped zip so the test is hermetic and returns a Path for zip_path.exists()
|
||||
mock_zip = project_dir / "mock.zip"
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", lambda self, ext_id: mock_zip)
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
# extension_update() catches exceptions internally and exits with code 1 on failure.
|
||||
assert result.exit_code == 1
|
||||
assert "AttributeError" not in result.output
|
||||
assert not isinstance(result.exception, AttributeError)
|
||||
|
||||
def test_extension_update_rollback_corrupted_config(project_dir, monkeypatch):
|
||||
"""Regression: extension update rollback must handle corrupted extensions.yml."""
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
# Write config with hooks: null; get_project_config() normalizes this to {}
|
||||
# so the backup captures {} and the restored config will have hooks: {}.
|
||||
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": None}))
|
||||
|
||||
# Mock update process to fail after backup
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {"version": "1.0.0", "enabled": True})
|
||||
|
||||
# Force failure in download_extension to trigger rollback
|
||||
def mock_download_fail(*args, **kwargs):
|
||||
# Corrupt the config BEFORE rollback is triggered
|
||||
config_path.write_text(yaml.dump("CORRUPTED"))
|
||||
raise Exception("Download failed")
|
||||
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
# Should handle Exception and NOT crash with AttributeError during rollback
|
||||
assert result.exit_code == 1
|
||||
assert "Download failed" in result.output
|
||||
assert not isinstance(result.exception, AttributeError)
|
||||
|
||||
# Verify hooks key was preserved (normalized to {} if it was null/corrupted)
|
||||
restored_config = yaml.safe_load(config_path.read_text())
|
||||
assert isinstance(restored_config, dict)
|
||||
assert "hooks" in restored_config
|
||||
assert restored_config["hooks"] == {}
|
||||
|
||||
|
||||
def test_extension_update_skills_backup_no_collision(project_dir, monkeypatch):
|
||||
"""Regression: skills agents name every command file SKILL.md (one per
|
||||
command subdirectory). Backup must keep the per-command path so rollback
|
||||
restores each skill's own content instead of overwriting them onto a
|
||||
single backup path."""
|
||||
monkeypatch.chdir(project_dir)
|
||||
|
||||
config_path = project_dir / ".specify" / "extensions.yml"
|
||||
config_path.write_text(yaml.dump({"installed": ["test-ext"], "hooks": {}}))
|
||||
|
||||
# Two skill command files with DISTINCT content, mirroring the claude
|
||||
# skills layout (.claude/skills/<name>/SKILL.md).
|
||||
skills_root = project_dir / ".claude" / "skills"
|
||||
plan_file = skills_root / "speckit-plan" / "SKILL.md"
|
||||
tasks_file = skills_root / "speckit-tasks" / "SKILL.md"
|
||||
plan_file.parent.mkdir(parents=True)
|
||||
tasks_file.parent.mkdir(parents=True)
|
||||
plan_file.write_text("PLAN CONTENT")
|
||||
tasks_file.write_text("TASKS CONTENT")
|
||||
|
||||
monkeypatch.setattr(ExtensionManager, "list_installed", lambda self: [{"id": "test-ext", "name": "Test Ext", "version": "1.0.0"}])
|
||||
monkeypatch.setattr(ExtensionRegistry, "get", lambda self, ext_id: {
|
||||
"version": "1.0.0",
|
||||
"enabled": True,
|
||||
"registered_commands": {"claude": ["speckit.plan", "speckit.tasks"]},
|
||||
})
|
||||
monkeypatch.setattr(ExtensionCatalog, "get_extension_info", lambda self, ext_id: {"id": "test-ext", "name": "Test Ext", "version": "1.1.0", "download_url": "https://example.com/ext.zip"})
|
||||
|
||||
# Fail at download (step 5, after the command backup in step 3). Delete the
|
||||
# originals first to simulate an install clobbering them, forcing rollback
|
||||
# to rely entirely on the backups.
|
||||
def mock_download_fail(self, ext_id):
|
||||
plan_file.unlink()
|
||||
tasks_file.unlink()
|
||||
raise Exception("Download failed")
|
||||
|
||||
monkeypatch.setattr(ExtensionCatalog, "download_extension", mock_download_fail)
|
||||
monkeypatch.setattr("typer.confirm", lambda _: True)
|
||||
|
||||
result = runner.invoke(app, ["extension", "update", "test-ext"], obj={"project_root": project_dir})
|
||||
|
||||
assert result.exit_code == 1
|
||||
# Rollback must restore EACH skill's own content, not a single collided copy.
|
||||
assert plan_file.exists() and tasks_file.exists()
|
||||
assert plan_file.read_text() == "PLAN CONTENT"
|
||||
assert tasks_file.read_text() == "TASKS CONTENT"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
"""Tests for GitHub-authenticated HTTP request helpers."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli._github_http import (
|
||||
build_github_request,
|
||||
resolve_github_release_asset_api_url,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildGitHubRequest:
|
||||
"""Tests for build_github_request() URL validation and auth handling."""
|
||||
|
||||
# --- URL Validation Tests ---
|
||||
|
||||
def test_empty_url_raises_value_error(self):
|
||||
"""build_github_request() must reject an empty string URL."""
|
||||
with pytest.raises(ValueError, match="url must not be empty"):
|
||||
build_github_request("")
|
||||
|
||||
def test_whitespace_url_raises_value_error(self):
|
||||
"""build_github_request() must reject a whitespace-only URL."""
|
||||
with pytest.raises(ValueError, match="url must not be empty"):
|
||||
build_github_request(" ")
|
||||
|
||||
def test_non_http_url_raises_value_error(self):
|
||||
"""build_github_request() must reject URLs without http/https scheme."""
|
||||
with pytest.raises(ValueError, match="url must start with http"):
|
||||
build_github_request("not-a-url")
|
||||
|
||||
def test_ftp_url_raises_value_error(self):
|
||||
"""build_github_request() must reject ftp:// URLs."""
|
||||
with pytest.raises(ValueError, match="url must start with http"):
|
||||
build_github_request("ftp://github.com/file.zip")
|
||||
|
||||
# --- Valid URL Tests ---
|
||||
|
||||
def test_valid_https_url_returns_request(self):
|
||||
"""build_github_request() must return a Request for a valid https URL."""
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.full_url == "https://github.com/github/spec-kit"
|
||||
|
||||
def test_valid_http_url_returns_request(self):
|
||||
"""build_github_request() must accept http:// URLs."""
|
||||
req = build_github_request("http://example.com/file")
|
||||
assert req.full_url == "http://example.com/file"
|
||||
|
||||
# --- Auth Header Tests ---
|
||||
|
||||
def test_github_token_added_for_github_host(self):
|
||||
"""Authorization header is set when GITHUB_TOKEN is present."""
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token", "GH_TOKEN": ""}):
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.get_header("Authorization") == "Bearer test-token"
|
||||
|
||||
def test_gh_token_used_as_fallback(self):
|
||||
"""GH_TOKEN is used when GITHUB_TOKEN is absent."""
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "", "GH_TOKEN": "fallback-token"}):
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.get_header("Authorization") == "Bearer fallback-token"
|
||||
|
||||
def test_no_auth_header_for_non_github_host(self):
|
||||
"""Authorization header must NOT be set for non-GitHub URLs."""
|
||||
with patch.dict(os.environ, {"GITHUB_TOKEN": "test-token"}):
|
||||
req = build_github_request("https://example.com/file")
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_no_auth_header_when_no_token(self):
|
||||
"""No Authorization header when no token is set in environment."""
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
req = build_github_request("https://github.com/github/spec-kit")
|
||||
assert req.get_header("Authorization") is None
|
||||
|
||||
def test_missing_hostname_raises_value_error(self):
|
||||
"""build_github_request() must reject URLs with valid scheme but no hostname."""
|
||||
with pytest.raises(ValueError, match="url must include a hostname"):
|
||||
build_github_request("http://")
|
||||
|
||||
|
||||
class TestResolveGitHubReleaseAssetApiUrl:
|
||||
"""Tests for resolve_github_release_asset_api_url()."""
|
||||
|
||||
def _make_open_url_fn(self, release_json):
|
||||
"""Create a fake open_url_fn that returns release JSON."""
|
||||
@contextmanager
|
||||
def fake_open(url, timeout=None, extra_headers=None):
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(release_json).encode()
|
||||
yield resp
|
||||
return fake_open
|
||||
|
||||
def test_returns_none_for_non_github_url(self):
|
||||
"""Non-GitHub URLs should return None."""
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://example.com/file.zip", lambda *a, **kw: None
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_non_release_github_url(self):
|
||||
"""GitHub URLs that aren't release downloads return None."""
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/archive/refs/tags/v1.zip",
|
||||
lambda *a, **kw: None,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_passthrough_for_existing_api_asset_url(self):
|
||||
"""Already-resolved REST API asset URLs are returned as-is."""
|
||||
url = "https://api.github.com/repos/org/repo/releases/assets/12345"
|
||||
result = resolve_github_release_asset_api_url(url, lambda *a, **kw: None)
|
||||
assert result == url
|
||||
|
||||
def test_resolves_browser_url_to_api_url(self):
|
||||
"""Browser release URL resolves to REST API asset URL."""
|
||||
release_json = {
|
||||
"assets": [
|
||||
{"name": "pack.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/99"}
|
||||
]
|
||||
}
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
|
||||
self._make_open_url_fn(release_json),
|
||||
)
|
||||
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
|
||||
def test_returns_none_when_asset_not_found(self):
|
||||
"""Returns None when the release exists but asset name doesn't match."""
|
||||
release_json = {"assets": [{"name": "other.zip", "url": "https://api.github.com/repos/org/repo/releases/assets/1"}]}
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1/missing.zip",
|
||||
self._make_open_url_fn(release_json),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_on_network_error(self):
|
||||
"""Returns None when the API request fails."""
|
||||
import urllib.error
|
||||
|
||||
@contextmanager
|
||||
def failing_open(url, timeout=None, extra_headers=None):
|
||||
raise urllib.error.URLError("network error")
|
||||
yield # noqa: unreachable
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1/pack.zip",
|
||||
failing_open,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_tag_with_special_characters_is_url_encoded(self):
|
||||
"""Tags with reserved characters (e.g. '/') are encoded in the API URL."""
|
||||
captured_urls = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/feature%2Fv1/pack.zip",
|
||||
capturing_open,
|
||||
)
|
||||
# The tag "feature/v1" (decoded from %2F) must be re-encoded as "feature%2Fv1"
|
||||
assert len(captured_urls) == 1
|
||||
assert "releases/tags/feature%2Fv1" in captured_urls[0]
|
||||
|
||||
def test_tag_with_hash_is_url_encoded(self):
|
||||
"""Tags with '#' character are properly encoded."""
|
||||
captured_urls = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1%23beta/pack.zip",
|
||||
capturing_open,
|
||||
)
|
||||
assert len(captured_urls) == 1
|
||||
assert "releases/tags/v1%23beta" in captured_urls[0]
|
||||
|
||||
# --- GHES (GitHub Enterprise Server) ---
|
||||
|
||||
def test_resolves_ghes_browser_url_to_api_url(self):
|
||||
"""A GHES browser release URL resolves to the /api/v3 asset URL."""
|
||||
release_json = {
|
||||
"assets": [
|
||||
{"name": "ext.zip",
|
||||
"url": "https://ghes.example/api/v3/repos/o/r/releases/assets/7"}
|
||||
]
|
||||
}
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
self._make_open_url_fn(release_json),
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result == "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
|
||||
def test_passthrough_for_existing_ghes_api_asset_url(self):
|
||||
"""An already-resolved GHES /api/v3 asset URL is returned as-is."""
|
||||
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
result = resolve_github_release_asset_api_url(
|
||||
url, lambda *a, **kw: None, github_hosts=("ghes.example",)
|
||||
)
|
||||
assert result == url
|
||||
|
||||
def test_returns_none_for_ghes_host_not_in_allowlist(self):
|
||||
"""Unlisted hosts get no GHES treatment and trigger no API call (anti-SSRF)."""
|
||||
called = []
|
||||
|
||||
@contextmanager
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
recording_open,
|
||||
github_hosts=("other.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_returns_none_on_malformed_ghes_port(self):
|
||||
"""A malformed port on an allowlisted GHES host returns None, not a
|
||||
ValueError (contract: resolve or return None, never raise)."""
|
||||
called = []
|
||||
|
||||
def open_never(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
raise AssertionError("open_url_fn must not be called")
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example:notaport/o/r/releases/download/v1/ext.zip",
|
||||
open_never,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
helper gates the token independently, so octet-stream resolution must
|
||||
not be withheld."""
|
||||
called = []
|
||||
|
||||
@contextmanager
|
||||
def recording_open(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b"{}"
|
||||
yield resp
|
||||
|
||||
url = "https://ghes.example/api/v3/repos/o/r/releases/assets/7"
|
||||
result = resolve_github_release_asset_api_url(
|
||||
url, recording_open, github_hosts=("other.example",)
|
||||
)
|
||||
assert result == url
|
||||
assert called == []
|
||||
|
||||
def test_ghes_api_base_preserves_scheme_and_port(self):
|
||||
"""The GHES API base mirrors the URL scheme and keeps a non-standard port."""
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({"assets": []}).encode()
|
||||
yield resp
|
||||
|
||||
resolve_github_release_asset_api_url(
|
||||
"http://localhost:8000/o/r/releases/download/v1/ext.zip",
|
||||
capturing_open,
|
||||
github_hosts=("localhost",),
|
||||
)
|
||||
assert captured == ["http://localhost:8000/api/v3/repos/o/r/releases/tags/v1"]
|
||||
|
||||
def test_ghes_wildcard_does_not_match_bare_host(self):
|
||||
"""A '*.suffix' pattern does not match the bare host (must list it explicitly)."""
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://ghes.example/o/r/releases/download/v1/ext.zip",
|
||||
lambda *a, **kw: None,
|
||||
github_hosts=("*.ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
|
||||
def test_public_github_url_unaffected_by_github_hosts(self):
|
||||
"""Public github.com still resolves via api.github.com even with github_hosts set."""
|
||||
captured = []
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps({
|
||||
"assets": [{"name": "pack.zip",
|
||||
"url": "https://api.github.com/repos/org/repo/releases/assets/99"}]
|
||||
}).encode()
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/v1.0/pack.zip",
|
||||
capturing_open,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Static checks for repository GitHub Actions workflows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
|
||||
# Match both the dedicated-step form (` uses: x@sha`) and the
|
||||
# inline shorthand (` - uses: x@sha`) used in catalog-assign.yml.
|
||||
USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P<ref>\S+)", re.MULTILINE)
|
||||
PINNED_SHA_RE = re.compile(r"@[0-9a-f]{40}$", re.IGNORECASE)
|
||||
|
||||
|
||||
def test_github_actions_are_pinned_to_full_commit_shas():
|
||||
unpinned_refs = []
|
||||
|
||||
workflows = sorted(
|
||||
list(WORKFLOWS_DIR.glob("*.yml")) + list(WORKFLOWS_DIR.glob("*.yaml"))
|
||||
)
|
||||
assert workflows
|
||||
|
||||
for workflow in workflows:
|
||||
workflow_text = workflow.read_text(encoding="utf-8")
|
||||
for match in USES_RE.finditer(workflow_text):
|
||||
uses_ref = match.group("ref")
|
||||
if uses_ref.startswith(("./", "../")):
|
||||
continue
|
||||
if PINNED_SHA_RE.search(uses_ref):
|
||||
continue
|
||||
unpinned_refs.append(f"{workflow.relative_to(REPO_ROOT)}: {uses_ref}")
|
||||
|
||||
assert unpinned_refs == []
|
||||
|
||||
|
||||
def test_pinned_action_ref_accepts_uppercase_hex_sha():
|
||||
assert PINNED_SHA_RE.search(
|
||||
"actions/example@0123456789ABCDEF0123456789ABCDEF01234567"
|
||||
)
|
||||
@@ -0,0 +1,467 @@
|
||||
"""Tests for the SPECIFY_INIT_DIR project-root override.
|
||||
|
||||
SPECIFY_INIT_DIR lets a non-interactive / CI caller target a member project from
|
||||
outside its directory (e.g. a monorepo root) without `cd`. It names the project
|
||||
root — the directory *containing* `.specify/` — and is strict: it must exist and
|
||||
contain `.specify/`, otherwise the resolver hard-errors with no silent fallback to
|
||||
cwd or the git toplevel.
|
||||
|
||||
See proposals/monorepo-support and github/spec-kit discussion #2834.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import requires_bash
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
|
||||
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
|
||||
GIT_CREATE_FEATURE_SH = (
|
||||
PROJECT_ROOT / "extensions" / "git" / "scripts" / "bash" / "create-new-feature-branch.sh"
|
||||
)
|
||||
|
||||
HAS_PWSH = shutil.which("pwsh") is not None
|
||||
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||
_PS_EXE = "pwsh" if HAS_PWSH else _POWERSHELL
|
||||
|
||||
|
||||
def _clean_env() -> dict[str, str]:
|
||||
"""Inherited env minus all SPECIFY_* vars, so a developer/CI override
|
||||
(SPECIFY_FEATURE, SPECIFY_FEATURE_DIRECTORY, …) cannot leak into the
|
||||
subprocess and make these resolution tests flaky."""
|
||||
env = os.environ.copy()
|
||||
for key in list(env):
|
||||
if key.startswith("SPECIFY_"):
|
||||
env.pop(key)
|
||||
return env
|
||||
|
||||
|
||||
def _make_project(root: Path, name: str) -> Path:
|
||||
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
|
||||
proj = root / name
|
||||
(proj / ".specify").mkdir(parents=True)
|
||||
return proj
|
||||
|
||||
|
||||
def _bash(func_call: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
"""Source the real common.sh and run a function, from a given cwd/env."""
|
||||
return subprocess.run(
|
||||
["bash", "-c", f'source "{COMMON_SH}" && {func_call}'],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _ps(script: str, cwd: Path, env: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
"""Dot-source the real common.ps1 and run PowerShell, from a given cwd/env."""
|
||||
return subprocess.run(
|
||||
[_PS_EXE, "-NoProfile", "-Command", f'. "{COMMON_PS}"; {script}'],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _feature_dir_line(stdout: str) -> str | None:
|
||||
for line in stdout.splitlines():
|
||||
if line.startswith("FEATURE_DIR="):
|
||||
return line.split("=", 1)[1].strip("'\"")
|
||||
return None
|
||||
|
||||
|
||||
def _bash_path(path: Path) -> str:
|
||||
"""Return the path format emitted by Bash `pwd`.
|
||||
|
||||
Git-for-Windows Bash reports absolute paths as /c/... while pathlib reports
|
||||
them as C:\\..., so Bash stdout comparisons need an expected value in Bash's
|
||||
own path shape.
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return str(path)
|
||||
|
||||
resolved = path.resolve()
|
||||
path_str = str(resolved).replace("\\", "/")
|
||||
if resolved.drive.endswith(":"):
|
||||
return f"/{resolved.drive[0].lower()}{path_str[len(resolved.drive):]}"
|
||||
return path_str
|
||||
|
||||
|
||||
requires_pwsh = pytest.mark.skipif(
|
||||
not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available"
|
||||
)
|
||||
|
||||
|
||||
# ── Bash: positive cases ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_valid_path_resolves_from_outside(tmp_path: Path) -> None:
|
||||
"""P1: a valid project path resolves correctly when run from elsewhere."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _bash("get_repo_root", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
|
||||
"""P2: a relative SPECIFY_INIT_DIR is resolved against the current directory."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
|
||||
result = _bash("get_repo_root", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_trailing_slash_tolerated(tmp_path: Path) -> None:
|
||||
"""P3: a trailing slash is collapsed by normalization."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
|
||||
result = _bash("get_repo_root", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_precedence_over_cwd_project(tmp_path: Path) -> None:
|
||||
"""P4: feature resolution happens inside the *target* project, not cwd.
|
||||
|
||||
cwd is itself a valid Spec Kit project; SPECIFY_INIT_DIR must redirect
|
||||
resolution to the target project, so a relative SPECIFY_FEATURE_DIRECTORY
|
||||
normalizes under the target root, not cwd.
|
||||
"""
|
||||
cwd_proj = _make_project(tmp_path, "cwd_proj")
|
||||
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
|
||||
web = _make_project(tmp_path, "web")
|
||||
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
|
||||
}
|
||||
result = _bash("get_feature_paths", cwd=cwd_proj, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "001-demo")
|
||||
assert _bash_path(cwd_proj) not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_composes_with_feature_directory_override(tmp_path: Path) -> None:
|
||||
"""P5: SPECIFY_INIT_DIR (project axis) composes with SPECIFY_FEATURE_DIRECTORY
|
||||
(feature axis); a relative feature dir normalizes under the *target* root."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
|
||||
}
|
||||
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "003-x")
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_composes_with_target_feature_json(tmp_path: Path) -> None:
|
||||
"""P6: the target project's .specify/feature.json is honored."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
(web / ".specify" / "feature.json").write_text(
|
||||
'{"feature_directory": "specs/004-fj"}'
|
||||
)
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _bash("get_feature_paths", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert _feature_dir_line(result.stdout) == _bash_path(web / "specs" / "004-fj")
|
||||
|
||||
|
||||
# ── Bash: negative / contract cases ─────────────────────────────────────────
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_unset_preserves_cwd_walk(tmp_path: Path) -> None:
|
||||
"""N1: with SPECIFY_INIT_DIR unset, resolution walks up from cwd as before."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
result = _bash("get_repo_root", cwd=sub, env=_clean_env())
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_empty_string_treated_as_unset(tmp_path: Path) -> None:
|
||||
"""N2: an empty SPECIFY_INIT_DIR behaves as unset (not as ".").
|
||||
|
||||
Run from a deep subdirectory so the two interpretations diverge:
|
||||
empty-as-unset walks up to the project root; empty-as-"." would resolve to
|
||||
the cwd (which has no .specify/) and error. Asserting the walk-up result
|
||||
genuinely guards against a regression to "." semantics.
|
||||
"""
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
|
||||
result = _bash("get_repo_root", cwd=sub, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == _bash_path(web)
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_invalid_init_dir_fails_feature_paths_chain(tmp_path: Path) -> None:
|
||||
"""N5: an invalid SPECIFY_INIT_DIR hard-fails the load-bearing call site
|
||||
(get_feature_paths), not just get_repo_root — this is what the decl/assign
|
||||
split guards against (a `local x=$(get_repo_root)` would mask the failure
|
||||
and emit a FEATURE_DIR under the wrong root). SPECIFY_FEATURE_DIRECTORY is
|
||||
set so a feature dir *is* resolvable — only the propagation stops a
|
||||
wrong-root FEATURE_DIR, so a revert to the masked form fails this test."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(missing),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/001-x",
|
||||
}
|
||||
result = _bash("get_feature_paths", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert "FEATURE_DIR=" not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""N3: a non-existent path hard-errors — even from inside a valid project,
|
||||
proving there is no silent fallback to the cwd walk-up or git root."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
|
||||
result = _bash("get_repo_root", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert _bash_path(web) not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""N4: a path that exists but lacks .specify/ hard-errors, no fallback."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
nodot = tmp_path / "nodot"
|
||||
nodot.mkdir()
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
|
||||
result = _bash("get_repo_root", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "not a Spec Kit project" in result.stderr
|
||||
assert _bash_path(web) not in result.stdout
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_file_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""N4b: a path that exists but is a file (not a directory) hard-errors with
|
||||
the existing-directory message, with no fallback."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
a_file = tmp_path / "afile"
|
||||
a_file.write_text("x")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
|
||||
result = _bash("get_repo_root", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert _bash_path(web) not in result.stdout
|
||||
|
||||
|
||||
# ── Bash: bundled Git extension entrypoint ──────────────────────────────────
|
||||
|
||||
|
||||
def _bash_git_create(
|
||||
args: list[str], cwd: Path, env: dict[str, str]
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run the bundled git extension's create-new-feature-branch.sh (the real
|
||||
/speckit.specify before_specify entrypoint)."""
|
||||
return subprocess.run(
|
||||
["bash", str(GIT_CREATE_FEATURE_SH), *args],
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(stdout: str) -> dict | None:
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("{"):
|
||||
return json.loads(line)
|
||||
return None
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_git_ext_create_feature_numbers_from_target(tmp_path: Path) -> None:
|
||||
"""P8: the git extension's feature creation numbers from the SPECIFY_INIT_DIR
|
||||
project, not the cwd project."""
|
||||
(tmp_path / "specs" / "008-cwd").mkdir(parents=True) # cwd project's specs
|
||||
web = _make_project(tmp_path, "web")
|
||||
(web / ".specify" / "templates").mkdir(parents=True, exist_ok=True)
|
||||
(web / ".specify" / "templates" / "spec-template.md").write_text("# Spec: [FEATURE]\n")
|
||||
(web / "specs" / "005-existing").mkdir(parents=True)
|
||||
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _bash_git_create(["--json", "next thing"], cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
data = _json_line(result.stdout)
|
||||
assert data is not None and data["FEATURE_NUM"] == "006" # 005 in web → 006, not 009
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_git_ext_create_feature_invalid_init_dir_errors(tmp_path: Path) -> None:
|
||||
"""N7: the git extension hard-errors on an invalid SPECIFY_INIT_DIR with no
|
||||
fallback to the cwd/git-toplevel project."""
|
||||
web = _make_project(tmp_path, "web") # valid project at cwd
|
||||
(web / "specs" / "001-cwd").mkdir(parents=True)
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
|
||||
result = _bash_git_create(["--json", "x"], cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
assert _json_line(result.stdout) is None
|
||||
|
||||
|
||||
# ── PowerShell mirror (skipped only when no PowerShell is installed; the CI
|
||||
# ubuntu/windows runners ship pwsh, so these DO run there) ─────────────────
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_valid_path_resolves_from_outside(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(web)}
|
||||
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_relative_path_normalized_against_cwd(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": "web"}
|
||||
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_trailing_slash_tolerated(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": f"{web}/"}
|
||||
result = _ps("Get-RepoRoot", cwd=tmp_path, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_unset_preserves_cwd_walk(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
result = _ps("Get-RepoRoot", cwd=sub, env=_clean_env())
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_precedence_over_cwd_project(tmp_path: Path) -> None:
|
||||
cwd_proj = _make_project(tmp_path, "cwd_proj")
|
||||
(cwd_proj / "specs" / "001-cwd").mkdir(parents=True)
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/001-demo",
|
||||
}
|
||||
result = _ps(
|
||||
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
|
||||
cwd=cwd_proj,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# PowerShell Join-Path keeps the embedded "/" of the relative feature dir
|
||||
# while pathlib uses the platform separator; compare separator-insensitively
|
||||
# so the Windows CI runner (where pwsh runs) matches.
|
||||
feature_dir = _feature_dir_line(result.stdout)
|
||||
assert feature_dir is not None, result.stdout
|
||||
assert feature_dir.replace("\\", "/") == (web / "specs" / "001-demo").as_posix()
|
||||
assert str(cwd_proj) not in result.stdout
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_composes_with_feature_directory_override(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
env = {
|
||||
**_clean_env(),
|
||||
"SPECIFY_INIT_DIR": str(web),
|
||||
"SPECIFY_FEATURE_DIRECTORY": "specs/003-x",
|
||||
}
|
||||
result = _ps(
|
||||
'$r = Get-FeaturePathsEnv; Write-Output "FEATURE_DIR=$($r.FEATURE_DIR)"',
|
||||
cwd=tmp_path,
|
||||
env=env,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
# Separator-insensitive: PowerShell Join-Path keeps the embedded "/".
|
||||
feature_dir = _feature_dir_line(result.stdout)
|
||||
assert feature_dir is not None, result.stdout
|
||||
assert feature_dir.replace("\\", "/") == (web / "specs" / "003-x").as_posix()
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_empty_string_treated_as_unset(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
sub = web / "src" / "deep"
|
||||
sub.mkdir(parents=True)
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": ""}
|
||||
result = _ps("Get-RepoRoot", cwd=sub, env=env)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == str(web)
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_nonexistent_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
missing = tmp_path / "does_not_exist"
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(missing)}
|
||||
result = _ps("Get-RepoRoot", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_path_without_specify_errors_no_fallback(tmp_path: Path) -> None:
|
||||
web = _make_project(tmp_path, "web")
|
||||
nodot = tmp_path / "nodot"
|
||||
nodot.mkdir()
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(nodot)}
|
||||
result = _ps("Get-RepoRoot", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "not a Spec Kit project" in result.stderr
|
||||
|
||||
|
||||
@requires_pwsh
|
||||
def test_ps_file_path_errors_no_fallback(tmp_path: Path) -> None:
|
||||
"""A file path resolves via Resolve-Path but is not a directory; the resolver
|
||||
must reject it with the existing-directory message, not not-a-project."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
a_file = tmp_path / "afile"
|
||||
a_file.write_text("x")
|
||||
env = {**_clean_env(), "SPECIFY_INIT_DIR": str(a_file)}
|
||||
result = _ps("Get-RepoRoot", cwd=web, env=env)
|
||||
assert result.returncode != 0
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Tests for the SPECIFY_INIT_DIR override in the Python CLI (`specify`).
|
||||
|
||||
PR #2892 taught the shell resolver (`get_repo_root` / `Get-RepoRoot`) to honor
|
||||
SPECIFY_INIT_DIR, so the core slash-command scripts can target a member project
|
||||
from a monorepo root. This extends the same validation rules to the Python CLI's
|
||||
project resolution — `_require_specify_project()` (the chokepoint for every
|
||||
project-scoped subcommand) and the `workflow run <file>` standalone-YAML path —
|
||||
so those can target a member project without `cd` too.
|
||||
|
||||
The contract mirrors `tests/test_init_dir.py` (the shell side): the value names
|
||||
the project root (the directory *containing* `.specify/`), relative paths
|
||||
resolve against cwd, and an invalid value hard-errors with no silent fallback to
|
||||
cwd. See proposals/monorepo-support and github/spec-kit discussion #2834.
|
||||
|
||||
SPECIFY_* vars are stripped from the environment for every test by the autouse
|
||||
`_strip_specify_env` fixture in conftest.py; tests that want an override set it
|
||||
explicitly via monkeypatch.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _make_project(root, name):
|
||||
"""Create <root>/<name>/.specify (the minimal Spec Kit project marker)."""
|
||||
proj = root / name
|
||||
(proj / ".specify").mkdir(parents=True)
|
||||
return proj
|
||||
|
||||
|
||||
def _workflow_yaml(wf_id):
|
||||
"""A minimal valid standalone workflow YAML with a single no-op shell step."""
|
||||
return yaml.dump(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflow": {
|
||||
"id": wf_id,
|
||||
"name": wf_id,
|
||||
"version": "1.0.0",
|
||||
"description": f"standalone workflow {wf_id}",
|
||||
},
|
||||
"steps": [{"id": "noop", "type": "shell", "run": "echo done"}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── chokepoint: _require_specify_project() via `workflow list` ───────────────
|
||||
# `workflow list` is the lightest subcommand routed through the chokepoint: it
|
||||
# resolves the project, then reads <project>/.specify/workflows/. An empty
|
||||
# project prints "No workflows installed"; a failed resolution prints the error
|
||||
# and exits non-zero.
|
||||
|
||||
|
||||
def test_override_redirects_to_sibling_from_nonproject_cwd(tmp_path, monkeypatch):
|
||||
"""A valid SPECIFY_INIT_DIR resolves the target even when cwd is not itself a
|
||||
project — without the override this would error 'Not a Spec Kit project'."""
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
web = _make_project(tmp_path, "web")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_override_relative_path_normalized_against_cwd(tmp_path, monkeypatch):
|
||||
web = _make_project(tmp_path, "web")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", "web")
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
assert web.exists()
|
||||
|
||||
|
||||
def test_override_trailing_slash_tolerated(tmp_path, monkeypatch):
|
||||
_make_project(tmp_path, "web")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", "web/")
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_override_redirects_bundle_commands(tmp_path, monkeypatch):
|
||||
web = _make_project(tmp_path, "web")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No bundles installed" in result.output
|
||||
|
||||
|
||||
def test_unset_override_uses_cwd(tmp_path, monkeypatch):
|
||||
"""With SPECIFY_INIT_DIR unset, the project is the current directory."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_empty_override_treated_as_unset(tmp_path, monkeypatch):
|
||||
"""An empty SPECIFY_INIT_DIR behaves as unset (falls through to cwd), not as
|
||||
'.' — which from a deep non-project cwd would otherwise diverge."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", "")
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No workflows installed" in result.output
|
||||
|
||||
|
||||
def test_override_nonexistent_errors_no_fallback(tmp_path, monkeypatch):
|
||||
"""A non-existent path hard-errors even from inside a valid project, proving
|
||||
there is no silent fallback to the cwd project."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
assert "No workflows installed" not in result.output # no fallback to cwd
|
||||
|
||||
|
||||
def test_override_nonexistent_errors_bundle_commands_no_fallback(tmp_path, monkeypatch):
|
||||
"""Bundle commands also honor the strict override contract."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
assert "No bundles installed" not in result.output
|
||||
|
||||
|
||||
def test_override_nonexistent_bundle_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
||||
"""Invalid override errors must not contaminate JSON stdout."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "list", "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert result.stdout == ""
|
||||
assert "does not point to an existing directory" in result.stderr
|
||||
|
||||
|
||||
def test_override_symlinked_specify_errors_bundle_init_no_fallback(tmp_path, monkeypatch):
|
||||
"""A symlinked override .specify must not make bundle init fall back to cwd."""
|
||||
web = tmp_path / "web"
|
||||
web.mkdir()
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
try:
|
||||
(web / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["bundle", "init", "--offline"])
|
||||
assert result.exit_code != 0
|
||||
assert "symlinked .specify" in result.output
|
||||
assert not (elsewhere / ".specify").exists()
|
||||
|
||||
|
||||
def test_override_without_specify_errors_no_fallback(tmp_path, monkeypatch):
|
||||
"""A path that exists but lacks .specify/ hard-errors, no fallback."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
nodot = tmp_path / "nodot"
|
||||
nodot.mkdir()
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(nodot))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "not a Spec Kit project" in result.output
|
||||
assert "No workflows installed" not in result.output
|
||||
|
||||
|
||||
def test_override_file_path_errors_no_fallback(tmp_path, monkeypatch):
|
||||
"""A path that is a file (not a directory) hard-errors with the
|
||||
existing-directory message."""
|
||||
cwd_proj = _make_project(tmp_path, "cwd")
|
||||
a_file = tmp_path / "afile"
|
||||
a_file.write_text("x")
|
||||
monkeypatch.chdir(cwd_proj)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(a_file))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "list"])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
|
||||
|
||||
# ── bypass: `workflow run <file>` ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_override_redirects_workflow_run_file(tmp_path, monkeypatch):
|
||||
"""Running a standalone YAML with SPECIFY_INIT_DIR set uses the target as the
|
||||
project root: run artifacts land under the target, not cwd."""
|
||||
web = _make_project(tmp_path, "web")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("override-run"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file)], catch_exceptions=False)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (web / ".specify" / "workflows" / "runs").is_dir()
|
||||
assert not (elsewhere / ".specify").exists() # cwd was not used as the project
|
||||
|
||||
|
||||
def test_override_invalid_errors_workflow_run_file(tmp_path, monkeypatch):
|
||||
"""An invalid SPECIFY_INIT_DIR hard-errors the file path too — no fallback to
|
||||
cwd's standalone-YAML behavior."""
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("x"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(tmp_path / "does_not_exist"))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
|
||||
assert result.exit_code != 0
|
||||
assert "does not point to an existing directory" in result.output
|
||||
|
||||
|
||||
def test_override_rejects_symlinked_specify(tmp_path, monkeypatch):
|
||||
"""`workflow run <file>` refuses a symlinked .specify under the override
|
||||
target, matching the guard the cwd path applies (the override resolver's
|
||||
is_dir() check follows symlinks, so this is re-checked on the override path)."""
|
||||
web = tmp_path / "web"
|
||||
web.mkdir()
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
try:
|
||||
(web / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("symlink-run"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file)])
|
||||
assert result.exit_code != 0
|
||||
assert "Refusing to use symlinked .specify path" in result.output
|
||||
|
||||
|
||||
def test_override_rejects_symlinked_specify_json_error_stays_off_stdout(tmp_path, monkeypatch):
|
||||
"""`workflow run --json <file>` must keep this hard error off stdout."""
|
||||
web = tmp_path / "web"
|
||||
web.mkdir()
|
||||
real = tmp_path / "real-specify"
|
||||
real.mkdir()
|
||||
try:
|
||||
(web / ".specify").symlink_to(real, target_is_directory=True)
|
||||
except (OSError, NotImplementedError):
|
||||
pytest.skip("Symlinks are not available in this environment")
|
||||
elsewhere = tmp_path / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
workflow_file = elsewhere / "wf.yml"
|
||||
workflow_file.write_text(_workflow_yaml("symlink-json-run"), encoding="utf-8")
|
||||
monkeypatch.chdir(elsewhere)
|
||||
monkeypatch.setenv("SPECIFY_INIT_DIR", str(web))
|
||||
|
||||
result = runner.invoke(app, ["workflow", "run", str(workflow_file), "--json"])
|
||||
assert result.exit_code != 0
|
||||
assert result.stdout == ""
|
||||
assert "Refusing to use symlinked .specify path" in result.stderr
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for Rich Live transient=False on Windows (GitHub issue #2927).
|
||||
|
||||
PowerShell 5.1's legacy console host does not support VT escape sequences
|
||||
reliably. Rich's ``Live(transient=True)`` attempts cursor restoration on
|
||||
exit, which hangs indefinitely on that console. The fix disables transient
|
||||
mode when ``sys.platform == "win32"``.
|
||||
|
||||
These tests patch ``sys.platform`` and intercept the ``Live`` constructor
|
||||
to verify the correct ``transient`` value reaches Rich.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _console.py — Live in the select_with_arrows helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _invoke_select_with_arrows(platform: str) -> bool:
|
||||
"""Patch sys.platform and Live, invoke select_with_arrows, return transient kwarg."""
|
||||
captured = {}
|
||||
|
||||
mock_live_instance = MagicMock()
|
||||
mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance)
|
||||
mock_live_instance.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
def fake_live(*args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return mock_live_instance
|
||||
|
||||
# Patch readchar so the loop immediately returns "enter"
|
||||
import readchar
|
||||
|
||||
with (
|
||||
patch("sys.platform", platform),
|
||||
patch("specify_cli._console.Live", side_effect=fake_live),
|
||||
patch("specify_cli._console.readchar.readkey", return_value=readchar.key.ENTER),
|
||||
):
|
||||
from specify_cli._console import select_with_arrows
|
||||
|
||||
select_with_arrows({"a": "Option A", "b": "Option B"}, "Pick one", "a")
|
||||
|
||||
return captured["transient"]
|
||||
|
||||
|
||||
class TestSelectWithArrowsLiveTransient:
|
||||
"""Verify that select_with_arrows passes transient=False on Windows."""
|
||||
|
||||
def test_transient_false_on_windows(self):
|
||||
assert _invoke_select_with_arrows("win32") is False
|
||||
|
||||
def test_transient_true_on_linux(self):
|
||||
assert _invoke_select_with_arrows("linux") is True
|
||||
|
||||
def test_transient_true_on_macos(self):
|
||||
assert _invoke_select_with_arrows("darwin") is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# init.py — verify source contains the platform guard (regression check)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSourceContainsPlatformGuard:
|
||||
"""Ensure the platform guard feeds into the Live() transient kwarg."""
|
||||
|
||||
# Single DOTALL regex: _transient assigned from win32 check, then used in Live()
|
||||
_GUARD_RE = r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*Live\(.*transient\s*=\s*_transient"
|
||||
|
||||
def test_init_has_win32_guard(self):
|
||||
"""init.py must assign _transient from platform check and pass it to Live."""
|
||||
import re
|
||||
|
||||
init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py"
|
||||
content = init_src.read_text(encoding="utf-8")
|
||||
assert re.search(self._GUARD_RE, content, re.DOTALL)
|
||||
|
||||
def test_console_has_win32_guard(self):
|
||||
"""_console.py must assign _transient from platform check and pass it to Live."""
|
||||
import re
|
||||
|
||||
console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py"
|
||||
content = console_src.read_text(encoding="utf-8")
|
||||
assert re.search(self._GUARD_RE, content, re.DOTALL)
|
||||
assert re.search(r"transient\s*=\s*_transient", content)
|
||||
assert "transient=_transient" in content
|
||||
@@ -0,0 +1,190 @@
|
||||
import stat
|
||||
|
||||
from specify_cli import merge_json_files
|
||||
from specify_cli import handle_vscode_settings
|
||||
|
||||
# --- Dimension 2: Polite Deep Merge Strategy ---
|
||||
|
||||
def test_merge_json_files_type_mismatch_preservation(tmp_path):
|
||||
"""If user has a string but template wants a dict, PRESERVE user's string."""
|
||||
existing_file = tmp_path / "settings.json"
|
||||
# User might have overridden a setting with a simple string or different type
|
||||
existing_file.write_text('{"chat.editor.fontFamily": "CustomFont"}')
|
||||
|
||||
# Template might expect a dict for the same key (hypothetically)
|
||||
new_settings = {
|
||||
"chat.editor.fontFamily": {"font": "TemplateFont"}
|
||||
}
|
||||
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
# Result is None because user settings were preserved and nothing else changed
|
||||
assert merged is None
|
||||
|
||||
def test_merge_json_files_deep_nesting(tmp_path):
|
||||
"""Verify deep recursive merging of new keys."""
|
||||
existing_file = tmp_path / "settings.json"
|
||||
existing_file.write_text("""
|
||||
{
|
||||
"a": {
|
||||
"b": {
|
||||
"c": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
new_settings = {
|
||||
"a": {
|
||||
"b": {
|
||||
"d": 2 # New nested key
|
||||
},
|
||||
"e": 3 # New mid-level key
|
||||
}
|
||||
}
|
||||
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
assert merged["a"]["b"]["c"] == 1
|
||||
assert merged["a"]["b"]["d"] == 2
|
||||
assert merged["a"]["e"] == 3
|
||||
|
||||
def test_merge_json_files_empty_existing(tmp_path):
|
||||
"""Merging into an empty/new file."""
|
||||
existing_file = tmp_path / "empty.json"
|
||||
existing_file.write_text("{}")
|
||||
|
||||
new_settings = {"a": 1}
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
assert merged == {"a": 1}
|
||||
|
||||
# --- Dimension 3: Real-world Simulation ---
|
||||
|
||||
def test_merge_vscode_realistic_scenario(tmp_path):
|
||||
"""A realistic VSCode settings.json with many existing preferences, comments, and trailing commas."""
|
||||
existing_file = tmp_path / "vscode_settings.json"
|
||||
existing_file.write_text("""
|
||||
{
|
||||
"editor.fontSize": 12,
|
||||
"editor.formatOnSave": true, /* block comment */
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/node_modules": true,
|
||||
},
|
||||
"chat.promptFilesRecommendations": {
|
||||
"existing.tool": true,
|
||||
} // User comment
|
||||
}
|
||||
""")
|
||||
|
||||
template_settings = {
|
||||
"chat.promptFilesRecommendations": {
|
||||
"speckit.specify": True,
|
||||
"speckit.plan": True
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
".specify/scripts/bash/": True
|
||||
}
|
||||
}
|
||||
|
||||
merged = merge_json_files(existing_file, template_settings)
|
||||
|
||||
# Check preservation
|
||||
assert merged["editor.fontSize"] == 12
|
||||
assert merged["files.exclude"]["**/.git"] is True
|
||||
assert merged["chat.promptFilesRecommendations"]["existing.tool"] is True
|
||||
|
||||
# Check additions
|
||||
assert merged["chat.promptFilesRecommendations"]["speckit.specify"] is True
|
||||
assert merged["chat.tools.terminal.autoApprove"][".specify/scripts/bash/"] is True
|
||||
|
||||
# --- Dimension 4: Error Handling & Robustness ---
|
||||
|
||||
def test_merge_json_files_with_bom(tmp_path):
|
||||
"""Test files with UTF-8 BOM (sometimes created on Windows)."""
|
||||
existing_file = tmp_path / "bom.json"
|
||||
content = '{"a": 1}'
|
||||
# Prepend UTF-8 BOM
|
||||
existing_file.write_bytes(b'\xef\xbb\xbf' + content.encode('utf-8'))
|
||||
|
||||
new_settings = {"b": 2}
|
||||
merged = merge_json_files(existing_file, new_settings)
|
||||
assert merged == {"a": 1, "b": 2}
|
||||
|
||||
def test_merge_json_files_not_a_dictionary_template(tmp_path):
|
||||
"""If for some reason new_content is not a dict, PRESERVE existing settings by returning None."""
|
||||
existing_file = tmp_path / "ok.json"
|
||||
existing_file.write_text('{"a": 1}')
|
||||
|
||||
# Secure fallback: return None to skip writing and avoid clobbering
|
||||
assert merge_json_files(existing_file, ["not", "a", "dict"]) is None
|
||||
|
||||
def test_merge_json_files_unparseable_existing(tmp_path):
|
||||
"""If the existing file is unparseable JSON, return None to avoid overwriting it."""
|
||||
bad_file = tmp_path / "bad.json"
|
||||
bad_file.write_text('{"a": 1, missing_value}') # Invalid JSON
|
||||
|
||||
assert merge_json_files(bad_file, {"b": 2}) is None
|
||||
|
||||
|
||||
def test_merge_json_files_list_preservation(tmp_path):
|
||||
"""Verify that existing list values are preserved and NOT merged or overwritten."""
|
||||
existing_file = tmp_path / "list.json"
|
||||
existing_file.write_text('{"my.list": ["user_item"]}')
|
||||
|
||||
template_settings = {
|
||||
"my.list": ["template_item"]
|
||||
}
|
||||
|
||||
merged = merge_json_files(existing_file, template_settings)
|
||||
# The polite merge policy says: keep existing values if they exist and aren't both dicts.
|
||||
# Since nothing changed, it returns None.
|
||||
assert merged is None
|
||||
|
||||
def test_merge_json_files_no_changes(tmp_path):
|
||||
"""If the merge doesn't introduce any new keys or changes, return None to skip rewrite."""
|
||||
existing_file = tmp_path / "no_change.json"
|
||||
existing_file.write_text('{"a": 1, "b": {"c": 2}}')
|
||||
|
||||
template_settings = {
|
||||
"a": 1, # Already exists
|
||||
"b": {"c": 2} # Already exists nested
|
||||
}
|
||||
|
||||
# Should return None because result == existing
|
||||
assert merge_json_files(existing_file, template_settings) is None
|
||||
|
||||
def test_merge_json_files_type_mismatch_no_op(tmp_path):
|
||||
"""If a key exists with different type and we preserve it, it might still result in no change."""
|
||||
existing_file = tmp_path / "mismatch_no_op.json"
|
||||
existing_file.write_text('{"a": "user_string"}')
|
||||
|
||||
template_settings = {
|
||||
"a": {"key": "template_dict"} # Mismatch, will be ignored
|
||||
}
|
||||
|
||||
# Should return None because we preserved the user's string and nothing else changed
|
||||
assert merge_json_files(existing_file, template_settings) is None
|
||||
|
||||
|
||||
def test_handle_vscode_settings_preserves_mode_on_atomic_write(tmp_path):
|
||||
"""Atomic rewrite should preserve existing file mode bits."""
|
||||
vscode_dir = tmp_path / ".vscode"
|
||||
vscode_dir.mkdir()
|
||||
dest_file = vscode_dir / "settings.json"
|
||||
template_file = tmp_path / "template_settings.json"
|
||||
|
||||
dest_file.write_text('{"a": 1}\n', encoding="utf-8")
|
||||
dest_file.chmod(0o640)
|
||||
before_mode = stat.S_IMODE(dest_file.stat().st_mode)
|
||||
|
||||
template_file.write_text('{"b": 2}\n', encoding="utf-8")
|
||||
|
||||
handle_vscode_settings(
|
||||
template_file,
|
||||
dest_file,
|
||||
"settings.json",
|
||||
verbose=False,
|
||||
tracker=None,
|
||||
)
|
||||
|
||||
after_mode = stat.S_IMODE(dest_file.stat().st_mode)
|
||||
assert after_mode == before_mode
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Tests for post_process_command_content() hook on IntegrationBase.
|
||||
|
||||
Verifies that the generalized post-processing hook:
|
||||
- Runs for non-skills format types (Markdown, TOML, YAML)
|
||||
- Does NOT run for skills-format agents
|
||||
- Default no-op returns content unchanged
|
||||
- Exceptions propagate to caller
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registrar():
|
||||
return CommandRegistrar()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_dir(tmp_path):
|
||||
"""Create a mock extension with a simple command template."""
|
||||
ext = tmp_path / "extension"
|
||||
ext.mkdir()
|
||||
cmd_dir = ext / "commands"
|
||||
cmd_dir.mkdir()
|
||||
return ext, cmd_dir
|
||||
|
||||
|
||||
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
|
||||
cmd_file = cmd_dir / name
|
||||
cmd_file.write_text(
|
||||
f"---\ndescription: Test command\n---\n\n{body}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return cmd_file
|
||||
|
||||
|
||||
class TestDefaultNoOp:
|
||||
def test_returns_content_unchanged(self):
|
||||
base = IntegrationBase()
|
||||
content = "Some command content\nwith multiple lines."
|
||||
assert base.post_process_command_content(content) == content
|
||||
|
||||
def test_empty_string(self):
|
||||
base = IntegrationBase()
|
||||
assert base.post_process_command_content("") == ""
|
||||
|
||||
|
||||
class TestMarkdownAgentPostProcess:
|
||||
def test_opencode_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
marker = "<!-- POST_PROCESSED -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestTomlAgentPostProcess:
|
||||
def test_gemini_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
gemini = get_integration("gemini")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
gemini.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"gemini", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestYamlAgentPostProcess:
|
||||
def test_goose_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
goose = get_integration("goose")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
goose.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"goose", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestSkillsAgentExcluded:
|
||||
def test_claude_post_process_not_called(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
marker = "<!-- SHOULD_NOT_APPEAR -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
claude.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
skill_file = (
|
||||
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
|
||||
)
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert marker not in content
|
||||
|
||||
def test_skills_agent_method_never_called(
|
||||
self, tmp_path, registrar, ext_dir
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
|
||||
with patch.object(
|
||||
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
|
||||
) as mock_method:
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
mock_method.assert_not_called()
|
||||
|
||||
|
||||
class TestExceptionPropagation:
|
||||
def test_hook_exception_propagates(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
|
||||
def _raise(self, content):
|
||||
raise RuntimeError("Hook failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _raise
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
with pytest.raises(RuntimeError, match="Hook failed"):
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
|
||||
class TestRegressionPlainTemplate:
|
||||
@pytest.mark.parametrize(
|
||||
"agent,path_pattern",
|
||||
[
|
||||
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
|
||||
("opencode", ".opencode/commands/speckit.test.plain.md"),
|
||||
],
|
||||
ids=["skills", "markdown"],
|
||||
)
|
||||
def test_plain_template_unchanged(
|
||||
self, tmp_path, registrar, ext_dir, agent, path_pattern
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
body_text = "This is a plain command with no special content.\n"
|
||||
_write_cmd(cmd_dir, name="plain.md", body=body_text)
|
||||
|
||||
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
|
||||
registrar.register_commands(
|
||||
agent, commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
output_file = tmp_path / path_pattern
|
||||
assert output_file.exists(), f"Output file missing for {agent}"
|
||||
content = output_file.read_text(encoding="utf-8")
|
||||
assert body_text.strip() in content, f"Body text missing in {agent} output"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user