Files
github-spec-kit/tests/integrations/test_integration_catalog.py
Manfred Riem fc3d1244c0 fix: replace shell-based context updates with marker-based upsert (#2259)
* Replace shell-based context updates with marker-based upsert

Replace ~3500 lines of bash/PowerShell agent context update scripts
with a Python-based approach using <!-- SPECKIT START/END --> markers.

IntegrationBase now manages the agent context file directly:
- upsert_context_section(): creates or updates the marked section at
  init/install/switch time with a directive to read the current plan
- remove_context_section(): removes the section at uninstall, deleting
  the file only if it becomes empty
- __CONTEXT_FILE__ placeholder in command templates is resolved per
  integration so the plan command references the correct agent file
- context_file is persisted in init-options.json for extension access

The plan command template instructs the LLM to update the plan
reference between the markers in the agent context file.

Removed:
- scripts/bash/update-agent-context.sh (857 lines)
- scripts/powershell/update-agent-context.ps1 (515 lines)
- 56 integration wrapper scripts (update-context.sh/.ps1)
- templates/agent-file-template.md
- agent_scripts frontmatter key and {AGENT_SCRIPT} replacement logic
- update-context reference from integration.json
- tests/test_cursor_frontmatter.py (tested deleted scripts)

Added:
- upsert/remove context section methods on IntegrationBase
- __CONTEXT_FILE__ placeholder support in process_template()
- context_file field in init-options.json (init/switch/uninstall)
- Per-integration tests: context file correctness, plan reference,
  init-options persistence (78 new context_file tests)
- End-to-end CLI validation across all 28 integrations

* fix: search for end marker after start marker in context section methods

Address Copilot review: content.find(CONTEXT_MARKER_END) searched from
the start of the file rather than after the located start marker. If
the file contained a stray end marker before the start marker, the
wrong slice could be replaced.

Now both upsert_context_section() and remove_context_section() pass
start_idx as the second argument to find() and validate end_idx >
start_idx before performing the replacement.

* fix: address Copilot review feedback on context section handling

1. Fix grammar in _build_context_section() directive text — add commas
   for a complete sentence.

2. Resolve __CONTEXT_FILE__ in resolve_skill_placeholders() — skills
   generated via extensions/presets for codex/kimi now replace the
   placeholder using the context_file value from init-options.json.

3. Handle Cursor .mdc frontmatter — when creating a new .mdc context
   file, prepend alwaysApply: true YAML frontmatter so Cursor
   auto-loads the rules.

4. Fix empty-file leading newline — when the context file exists but
   is empty, write the section directly instead of prepending a blank
   line.

* fix: address second round of Copilot review feedback

1. Ensure .mdc frontmatter on existing files — upsert_context_section()
   now checks for missing YAML frontmatter on .mdc files during updates
   (not just creation), so pre-existing Cursor files get alwaysApply.

2. Guard against context_file=None — use 'or ""' instead of a default
   arg so explicit null values in init-options.json don't cause a
   TypeError in str.replace().

3. Clean up .mdc files on removal — remove_context_section() treats
   files containing only the Speckit-generated frontmatter block as
   empty, deleting them rather than leaving orphaned frontmatter.

* fix: address third round of Copilot review feedback

1. CRLF-safe .mdc frontmatter check — use lstrip().startswith('---')
   instead of startswith('---\n') so CRLF files don't get duplicate
   frontmatter.

2. CRLF-safe .mdc removal check — normalize line endings before
   comparing against the sentinel frontmatter string.

3. Call remove_context_section() during integration_uninstall() — the
   manifest-only uninstall was leaving the managed SPECKIT markers
   behind in the agent context file.

4. Fix stale docstring — remove 'agent_scripts' mention from
   test_lean_commands_have_no_scripts().

* fix: address fourth round of Copilot review feedback

1. Remove unused script_type parameter from _write_integration_json()
   and all 3 call sites — the parameter was no longer referenced after
   the update-context script removal.

2. Fix _build_context_section() docstring — correct example path from
   '.specify/plans/plan.md' to 'specs/<feature>/plan.md'.

3. Improve .mdc frontmatter-only detection in remove_context_section()
   — use regex to match any YAML frontmatter block (not just the exact
   Speckit-generated one), so .mdc files with additional frontmatter
   keys are also cleaned up when no body content remains.

* fix: handle corrupted markers and parse .mdc frontmatter robustly

1. Handle partial/corrupted markers in upsert_context_section() —
   if only the START marker exists (no END), replace from START
   through EOF. If only the END marker exists, replace from BOF
   through END. This keeps upsert idempotent even when a user
   accidentally deletes one marker.

2. Parse .mdc YAML frontmatter properly — new _ensure_mdc_frontmatter()
   helper parses existing frontmatter and ensures alwaysApply: true is
   set, rather than just checking for the --- delimiter. Handles
   missing frontmatter, existing frontmatter without alwaysApply, and
   already-correct frontmatter.

* fix: preserve .mdc frontmatter, add tests, clean up on switch

1. Rewrite _ensure_mdc_frontmatter() with regex — preserves comments,
   formatting, and custom keys in existing frontmatter instead of
   destructively re-serializing via yaml.safe_dump(). Inserts or
   fixes alwaysApply: true in place.

2. Add 6 focused .mdc frontmatter tests to cursor-agent test file:
   new file creation, missing frontmatter, preserved custom keys,
   wrong alwaysApply value, idempotent upserts, removal cleanup.

3. Call remove_context_section() during integration switch Phase 1 —
   prevents stale SPECKIT markers from being left in the old
   integration's context file. Also clear context_file from
   init-options during the metadata reset.

* fix: remove unused MDC_FRONTMATTER, preserve inline comments, normalize bare CR

1. Remove unused MDC_FRONTMATTER class variable — dead code after
   _ensure_mdc_frontmatter() was rewritten with regex.

2. Preserve inline comments when fixing alwaysApply — the regex
   substitution now captures trailing '# comment' text and keeps it.

3. Normalize bare CR in upsert_context_section() — match the
   behavior of remove_context_section() which already normalizes
   both CRLF and bare CR.

4. Clarify .mdc removal comment — 'treat frontmatter-only as empty'
   instead of misleading 'strip frontmatter'.

* fix: handle corrupted markers in remove, CRLF-safe end-marker consumption

1. Handle corrupted markers in remove_context_section() — mirror
   upsert's behavior: start-only removes start→EOF, end-only removes
   BOF→end. Previously bailed out leaving partial markers behind.

2. CRLF-safe end-marker consumption — both upsert and remove now
   handle \r\n after the end marker, not just \n. Prevents extra
   blank lines at replacement boundaries in CRLF files.

3. Clarify path rule in plan template — distinguish filesystem
   operations (absolute paths) from documentation/agent context
   references (project-relative paths).

* fix: only remove context section when both markers are well-ordered

remove_context_section() previously treated mismatched markers as
corruption and aggressively removed from BOF→end-marker or
start-marker→EOF, which could delete user-authored content if only
one marker remained. Now it only removes when both START and END
markers exist and are properly ordered, returning False otherwise.
2026-04-17 13:57:51 -05:00

657 lines
24 KiB
Python

"""Tests for the integration catalog system (catalog.py)."""
import json
import os
import pytest
import yaml
from specify_cli.integrations.catalog import (
IntegrationCatalog,
IntegrationCatalogEntry,
IntegrationCatalogError,
IntegrationDescriptor,
IntegrationDescriptorError,
)
# ---------------------------------------------------------------------------
# IntegrationCatalogEntry
# ---------------------------------------------------------------------------
class TestIntegrationCatalogEntry:
def test_create_entry(self):
entry = IntegrationCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=True,
description="Test catalog",
)
assert entry.url == "https://example.com/catalog.json"
assert entry.name == "test"
assert entry.priority == 1
assert entry.install_allowed is True
assert entry.description == "Test catalog"
def test_default_description(self):
entry = IntegrationCatalogEntry(
url="https://example.com/catalog.json",
name="test",
priority=1,
install_allowed=False,
)
assert entry.description == ""
# ---------------------------------------------------------------------------
# IntegrationCatalog — URL validation
# ---------------------------------------------------------------------------
class TestCatalogURLValidation:
def test_https_allowed(self):
IntegrationCatalog._validate_catalog_url("https://example.com/catalog.json")
def test_http_rejected(self):
with pytest.raises(IntegrationCatalogError, match="HTTPS"):
IntegrationCatalog._validate_catalog_url("http://example.com/catalog.json")
def test_http_localhost_allowed(self):
IntegrationCatalog._validate_catalog_url("http://localhost:8080/catalog.json")
IntegrationCatalog._validate_catalog_url("http://127.0.0.1/catalog.json")
def test_missing_host_rejected(self):
with pytest.raises(IntegrationCatalogError, match="valid URL"):
IntegrationCatalog._validate_catalog_url("https:///no-host")
# ---------------------------------------------------------------------------
# IntegrationCatalog — active catalogs
# ---------------------------------------------------------------------------
class TestActiveCatalogs:
def test_defaults_when_no_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
active = cat.get_active_catalogs()
assert len(active) == 2
assert active[0].name == "default"
assert active[1].name == "community"
def test_env_var_override(self, tmp_path, monkeypatch):
(tmp_path / ".specify").mkdir()
monkeypatch.setenv(
"SPECKIT_INTEGRATION_CATALOG_URL",
"https://custom.example.com/catalog.json",
)
cat = IntegrationCatalog(tmp_path)
active = cat.get_active_catalogs()
assert len(active) == 1
assert active[0].name == "custom"
def test_project_config_overrides_defaults(self, tmp_path):
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({
"catalogs": [
{"url": "https://my.example.com/cat.json", "name": "mine", "priority": 1, "install_allowed": True},
]
}))
cat = IntegrationCatalog(tmp_path)
active = cat.get_active_catalogs()
assert len(active) == 1
assert active[0].name == "mine"
def test_empty_config_raises(self, tmp_path):
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({"catalogs": []}))
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"):
cat.get_active_catalogs()
# ---------------------------------------------------------------------------
# IntegrationCatalog — fetch & search (using monkeypatched urlopen responses)
# ---------------------------------------------------------------------------
class TestCatalogFetch:
"""Tests that use a local HTTP server stub via monkeypatch."""
def _patch_urlopen(self, monkeypatch, catalog_data):
"""Patch urllib.request.urlopen to return *catalog_data*."""
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
def read(self):
return self._data
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
def fake_urlopen(url, timeout=10):
return FakeResponse(catalog_data, url)
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
def test_fetch_and_search_all(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"acme-coder": {
"id": "acme-coder",
"name": "Acme Coder",
"version": "2.0.0",
"description": "Community integration for Acme Coder",
"author": "acme-org",
"tags": ["cli"],
},
},
}
self._patch_urlopen(monkeypatch, catalog)
results = cat.search()
assert len(results) >= 1
ids = [r["id"] for r in results]
assert "acme-coder" in ids
def test_search_by_tag(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"a": {"id": "a", "name": "A", "version": "1.0.0", "tags": ["cli"]},
"b": {"id": "b", "name": "B", "version": "1.0.0", "tags": ["ide"]},
},
}
self._patch_urlopen(monkeypatch, catalog)
results = cat.search(tag="cli")
assert all("cli" in r.get("tags", []) for r in results)
def test_search_by_query(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0", "description": "Anthropic", "tags": []},
"gemini": {"id": "gemini", "name": "Gemini CLI", "version": "1.0.0", "description": "Google", "tags": []},
},
}
self._patch_urlopen(monkeypatch, catalog)
results = cat.search(query="claude")
assert len(results) == 1
assert results[0]["id"] == "claude"
def test_get_integration_info(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"claude": {"id": "claude", "name": "Claude Code", "version": "1.0.0"},
},
}
self._patch_urlopen(monkeypatch, catalog)
info = cat.get_integration_info("claude")
assert info is not None
assert info["name"] == "Claude Code"
assert cat.get_integration_info("nonexistent") is None
def test_invalid_catalog_format(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
self._patch_urlopen(monkeypatch, {"schema_version": "1.0"}) # missing "integrations"
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
cat.search()
def test_clear_cache(self, tmp_path):
(tmp_path / ".specify").mkdir()
cat = IntegrationCatalog(tmp_path)
cat.cache_dir.mkdir(parents=True, exist_ok=True)
(cat.cache_dir / "catalog-abc123.json").write_text("{}")
cat.clear_cache()
assert not list(cat.cache_dir.glob("catalog-*.json"))
# ---------------------------------------------------------------------------
# IntegrationDescriptor (integration.yml)
# ---------------------------------------------------------------------------
VALID_DESCRIPTOR = {
"schema_version": "1.0",
"integration": {
"id": "my-agent",
"name": "My Agent",
"version": "1.0.0",
"description": "Integration for My Agent",
"author": "my-org",
},
"requires": {
"speckit_version": ">=0.6.0",
},
"provides": {
"commands": [
{"name": "speckit.specify", "file": "templates/speckit.specify.md"},
],
"scripts": [],
},
}
class TestIntegrationDescriptor:
def _write(self, tmp_path, data):
p = tmp_path / "integration.yml"
p.write_text(yaml.dump(data))
return p
def test_valid_descriptor(self, tmp_path):
p = self._write(tmp_path, VALID_DESCRIPTOR)
desc = IntegrationDescriptor(p)
assert desc.id == "my-agent"
assert desc.name == "My Agent"
assert desc.version == "1.0.0"
assert desc.description == "Integration for My Agent"
assert desc.requires_speckit_version == ">=0.6.0"
assert len(desc.commands) == 1
assert desc.scripts == []
def test_missing_schema_version(self, tmp_path):
data = {**VALID_DESCRIPTOR}
del data["schema_version"]
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Missing required field: schema_version"):
IntegrationDescriptor(p)
def test_unsupported_schema_version(self, tmp_path):
data = {**VALID_DESCRIPTOR, "schema_version": "99.0"}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Unsupported schema version"):
IntegrationDescriptor(p)
def test_missing_integration_id(self, tmp_path):
data = {**VALID_DESCRIPTOR, "integration": {"name": "X", "version": "1.0.0", "description": "Y"}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Missing integration.id"):
IntegrationDescriptor(p)
def test_invalid_id_format(self, tmp_path):
integ = {**VALID_DESCRIPTOR["integration"], "id": "BAD_ID"}
data = {**VALID_DESCRIPTOR, "integration": integ}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Invalid integration ID"):
IntegrationDescriptor(p)
def test_invalid_version(self, tmp_path):
integ = {**VALID_DESCRIPTOR["integration"], "version": "not-semver"}
data = {**VALID_DESCRIPTOR, "integration": integ}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="Invalid version"):
IntegrationDescriptor(p)
def test_missing_speckit_version(self, tmp_path):
data = {**VALID_DESCRIPTOR, "requires": {}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="requires.speckit_version"):
IntegrationDescriptor(p)
def test_no_commands_or_scripts(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="at least one command or script"):
IntegrationDescriptor(p)
def test_command_missing_name(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"file": "x.md"}]}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="missing 'name' or 'file'"):
IntegrationDescriptor(p)
def test_commands_not_a_list(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {"commands": "not-a-list", "scripts": ["a.sh"]}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="expected a list"):
IntegrationDescriptor(p)
def test_scripts_not_a_list(self, tmp_path):
data = {**VALID_DESCRIPTOR, "provides": {"commands": [{"name": "a", "file": "b"}], "scripts": "not-a-list"}}
p = self._write(tmp_path, data)
with pytest.raises(IntegrationDescriptorError, match="expected a list"):
IntegrationDescriptor(p)
def test_file_not_found(self, tmp_path):
with pytest.raises(IntegrationDescriptorError, match="Descriptor not found"):
IntegrationDescriptor(tmp_path / "nonexistent.yml")
def test_invalid_yaml(self, tmp_path):
p = tmp_path / "integration.yml"
p.write_text(": : :")
with pytest.raises(IntegrationDescriptorError, match="Invalid YAML"):
IntegrationDescriptor(p)
def test_get_hash(self, tmp_path):
p = self._write(tmp_path, VALID_DESCRIPTOR)
desc = IntegrationDescriptor(p)
h = desc.get_hash()
assert h.startswith("sha256:")
def test_tools_accessor(self, tmp_path):
data = {**VALID_DESCRIPTOR, "requires": {
"speckit_version": ">=0.6.0",
"tools": [{"name": "my-agent", "version": ">=1.0.0", "required": True}],
}}
p = self._write(tmp_path, data)
desc = IntegrationDescriptor(p)
assert len(desc.tools) == 1
assert desc.tools[0]["name"] == "my-agent"
# ---------------------------------------------------------------------------
# CLI: integration list --catalog
# ---------------------------------------------------------------------------
class TestIntegrationListCatalog:
"""Test ``specify integration list --catalog``."""
def _init_project(self, tmp_path):
"""Create a minimal spec-kit project."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "proj"
project.mkdir()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here",
"--integration", "copilot",
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0, result.output
return project
def test_list_catalog_flag(self, tmp_path, monkeypatch):
"""--catalog should show catalog entries."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path)
catalog = {
"schema_version": "1.0",
"updated_at": "2026-01-01T00:00:00Z",
"integrations": {
"test-agent": {
"id": "test-agent",
"name": "Test Agent",
"version": "1.0.0",
"description": "A test agent",
"tags": ["cli"],
},
},
}
import urllib.request
class FakeResponse:
def __init__(self, data, url=""):
self._data = json.dumps(data).encode()
self._url = url
def read(self):
return self._data
def geturl(self):
return self._url
def __enter__(self):
return self
def __exit__(self, *a):
pass
monkeypatch.setattr(urllib.request, "urlopen", lambda url, timeout=10: FakeResponse(catalog, url))
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list", "--catalog"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "test-agent" in result.output
assert "Test Agent" in result.output
def test_list_without_catalog_still_works(self, tmp_path):
"""Default list (no --catalog) works as before."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path)
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "copilot" in result.output
assert "installed" in result.output
# ---------------------------------------------------------------------------
# CLI: integration upgrade
# ---------------------------------------------------------------------------
class TestIntegrationUpgrade:
"""Test ``specify integration upgrade``."""
def _init_project(self, tmp_path, integration="copilot"):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "proj"
project.mkdir()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"init", "--here",
"--integration", integration,
"--script", "sh",
"--no-git",
"--ignore-agent-tools",
], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0, result.output
return project
def test_upgrade_requires_speckit_project(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
old = os.getcwd()
try:
os.chdir(tmp_path)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code != 0
assert "Not a spec-kit project" in result.output
def test_upgrade_no_integration_installed(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = tmp_path / "proj"
project.mkdir()
(project / ".specify").mkdir()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "No integration is currently installed" in result.output
def test_upgrade_succeeds(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0
assert "upgraded successfully" in result.output
def test_upgrade_blocks_on_modified_files(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
# Modify a tracked file so the manifest hash won't match
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
assert manifest_path.exists(), "Manifest should exist after init"
manifest_data = json.loads(manifest_path.read_text())
tracked_files = manifest_data.get("files", {})
assert tracked_files, "Manifest should track at least one file"
first_rel = next(iter(tracked_files))
target_file = project / first_rel
assert target_file.exists(), f"Tracked file {first_rel} should exist"
target_file.write_text("MODIFIED CONTENT\n")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code != 0
assert "modified" in result.output.lower()
def test_upgrade_force_overwrites_modified(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
# Modify a tracked file
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
manifest_data = json.loads(manifest_path.read_text())
tracked_files = manifest_data.get("files", {})
assert tracked_files, "Manifest should track at least one file"
first_rel = next(iter(tracked_files))
target_file = project / first_rel
assert target_file.exists(), f"Tracked file {first_rel} should exist"
target_file.write_text("MODIFIED CONTENT\n")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade", "--force"], catch_exceptions=False)
finally:
os.chdir(old)
assert result.exit_code == 0
assert "upgraded successfully" in result.output
def test_upgrade_wrong_integration_key(self, tmp_path):
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade", "claude"])
finally:
os.chdir(old)
assert result.exit_code != 0
assert "not the currently installed integration" in result.output
def test_upgrade_no_manifest(self, tmp_path):
"""Upgrade with missing manifest suggests fresh install."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
project = self._init_project(tmp_path, "copilot")
# Remove manifest
manifest_path = project / ".specify" / "integrations" / "copilot.manifest.json"
if manifest_path.exists():
manifest_path.unlink()
old = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade"])
finally:
os.chdir(old)
assert result.exit_code == 0
assert "Nothing to upgrade" in result.output