mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 22:56:57 +08:00
feat: add catalog discovery CLI commands (#2360)
* feat: add catalog discovery CLI commands * fix: address second Copilot review * fix: address third Copilot review * fix: align catalog remove with displayed order * fix: route local catalog config errors to local guidance * fix: address integration catalog review feedback * fix: accept numeric string catalog priorities * fix: align catalog remove with visible entries * fix: preserve invalid catalog root validation * fix: include invalid catalog priority value * fix: preserve falsy catalog root validation * fix: clarify integration catalog guidance * fix: align integration catalog list and remove * fix: align integration catalog edge cases * fix: clarify catalog error guidance tests * fix: clarify integration catalog edge cases * fix: harden integration catalog removal * fix: validate integration state before catalog search * fix: reject empty integration catalog URL * fix: allow catalog remove to clean non-string URLs * fix: address catalog env and priority review * fix: align catalog source display names * fix: align catalog fallback names
This commit is contained in:
committed by
GitHub
parent
9cf3151a72
commit
1049e17a43
@@ -628,3 +628,550 @@ class TestSharedInfraCommandRefs:
|
||||
assert "/speckit-plan" in content, "Copilot --skills should use /speckit-plan"
|
||||
assert "/speckit.plan" not in content, "dot-notation leaked into Copilot skills page template"
|
||||
assert "__SPECKIT_COMMAND_" not in content
|
||||
|
||||
|
||||
class TestIntegrationCatalogDiscoveryCLI:
|
||||
"""End-to-end CLI tests for `integration search`, `info`, and `catalog …`.
|
||||
|
||||
All tests patch `IntegrationCatalog._get_merged_integrations` so no network
|
||||
or on-disk cache is touched. Adds #2344 coverage without affecting any
|
||||
existing integration install/switch/uninstall/upgrade behavior.
|
||||
"""
|
||||
|
||||
FAKE_INTEGRATIONS = [
|
||||
{
|
||||
"id": "acme-coder",
|
||||
"name": "Acme Coder",
|
||||
"version": "2.0.0",
|
||||
"description": "Community integration for Acme Coder",
|
||||
"author": "acme-org",
|
||||
"tags": ["cli", "acme"],
|
||||
"_catalog_name": "community",
|
||||
"_install_allowed": False,
|
||||
},
|
||||
{
|
||||
"id": "stellar-agent",
|
||||
"name": "Stellar Agent",
|
||||
"version": "1.3.0",
|
||||
"description": "First-party Stellar agent integration",
|
||||
"author": "stellar-labs",
|
||||
"tags": ["ide"],
|
||||
"_catalog_name": "default",
|
||||
"_install_allowed": True,
|
||||
},
|
||||
]
|
||||
|
||||
def _make_project(self, tmp_path):
|
||||
project = tmp_path / "proj"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
return project
|
||||
|
||||
def _patch_catalog(self, monkeypatch, integrations=None):
|
||||
"""Return a stubbed `_get_merged_integrations` that yields *integrations*."""
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
|
||||
data = list(integrations if integrations is not None else self.FAKE_INTEGRATIONS)
|
||||
|
||||
def fake_merged(self, force_refresh=False):
|
||||
return data
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "_get_merged_integrations", fake_merged)
|
||||
|
||||
def _invoke(self, argv, cwd):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(cwd)
|
||||
return runner.invoke(app, argv, catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
# -- Project guard -----------------------------------------------------
|
||||
|
||||
def test_search_requires_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
def test_catalog_list_requires_specify_project(self, tmp_path):
|
||||
project = tmp_path / "bare"
|
||||
project.mkdir()
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert result.exit_code == 1
|
||||
assert "Not a spec-kit project" in result.output
|
||||
|
||||
# -- search ------------------------------------------------------------
|
||||
|
||||
def test_search_lists_all(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Found 2 integration(s)" in result.output
|
||||
assert "acme-coder" in result.output
|
||||
assert "stellar-agent" in result.output
|
||||
assert "specify integration install stellar-agent" not in normalized_output
|
||||
assert "Only built-in integration IDs can be installed" in normalized_output
|
||||
|
||||
def test_search_validates_integration_json_before_catalog_lookup(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
(project / ".specify" / "integration.json").write_text(
|
||||
"{bad json\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
|
||||
def fail_search(self, **kwargs):
|
||||
raise AssertionError("catalog search should not be called")
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1
|
||||
assert "contains invalid JSON" in normalized_output
|
||||
assert "integration.json" in normalized_output
|
||||
|
||||
def test_search_filters_by_tag(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(["integration", "search", "--tag", "acme"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Found 1 integration(s)" in result.output
|
||||
assert "acme-coder" in result.output
|
||||
assert "stellar-agent" not in result.output
|
||||
|
||||
def test_search_filters_by_author(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "search", "--author", "stellar-labs"], project
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Found 1 integration(s)" in result.output
|
||||
assert "stellar-agent" in result.output
|
||||
|
||||
def test_search_no_match_hint(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "search", "--tag", "nope"], project
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No integrations found" in result.output
|
||||
assert "specify integration search" in result.output
|
||||
|
||||
def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(["integration", "search", "acme"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
# acme-coder is flagged _install_allowed=False, so we should warn
|
||||
assert "Not directly installable" in result.output
|
||||
|
||||
# -- info --------------------------------------------------------------
|
||||
|
||||
def test_info_found(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "info", "stellar-agent"], project
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Stellar Agent" in result.output
|
||||
assert "stellar-agent" in result.output
|
||||
assert "v1.3.0" in result.output
|
||||
|
||||
def test_info_not_found(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
self._patch_catalog(monkeypatch)
|
||||
result = self._invoke(
|
||||
["integration", "info", "does-not-exist"], project
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "not found" in result.output
|
||||
|
||||
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
# Empty catalog, but copilot is a registered built-in.
|
||||
self._patch_catalog(monkeypatch, integrations=[])
|
||||
result = self._invoke(["integration", "info", "copilot"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Built-in integration" in result.output
|
||||
|
||||
# -- validation vs network guidance ------------------------------------
|
||||
|
||||
def test_search_local_config_error_shows_local_config_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""`integration search` must point at .specify/integration-catalogs.yml
|
||||
for local-config errors (not the generic 'temporarily unavailable')."""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
# Corrupt YAML to drive _load_catalog_config -> IntegrationValidationError.
|
||||
cfg = project / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - [bad\n"
|
||||
cfg.write_text(invalid_yaml, encoding="utf-8")
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "configuration file path shown above" in normalized_output
|
||||
assert ".specify/integration-catalogs.yml" in normalized_output
|
||||
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
||||
assert "temporarily unavailable" not in normalized_output
|
||||
|
||||
def test_search_invalid_env_catalog_url_shows_env_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL",
|
||||
"http://insecure.example.com/catalog.json",
|
||||
)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "SPECKIT_INTEGRATION_CATALOG_URL environment variable" in normalized_output
|
||||
assert "unset it to use the configured catalog files" in normalized_output
|
||||
assert ".specify/integration-catalogs.yml" in normalized_output
|
||||
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
||||
assert "temporarily unavailable" not in normalized_output
|
||||
|
||||
def test_search_whitespace_env_catalog_url_uses_generic_catalog_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CATALOG_URL", " ")
|
||||
|
||||
from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogError,
|
||||
)
|
||||
|
||||
def fail_search(self, **kwargs):
|
||||
raise IntegrationCatalogError("catalog offline")
|
||||
|
||||
monkeypatch.setattr(IntegrationCatalog, "search", fail_search)
|
||||
|
||||
result = self._invoke(["integration", "search"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "temporarily unavailable" in normalized_output
|
||||
assert (
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL environment variable"
|
||||
not in normalized_output
|
||||
)
|
||||
|
||||
def test_info_unknown_with_local_config_error_shows_local_config_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""`integration info <unknown>` falls back to the catalog-error branch
|
||||
and must show local-config guidance, not 'Try again when online'."""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
cfg = project / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - [bad\n"
|
||||
cfg.write_text(invalid_yaml, encoding="utf-8")
|
||||
|
||||
result = self._invoke(
|
||||
["integration", "info", "definitely-not-real"], project
|
||||
)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "configuration file path shown above" in normalized_output
|
||||
assert ".specify/integration-catalogs.yml" in normalized_output
|
||||
assert "~/.specify/integration-catalogs.yml" in normalized_output
|
||||
assert "Try again when online" not in normalized_output
|
||||
|
||||
def test_info_unknown_with_invalid_env_catalog_url_shows_env_tip(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL",
|
||||
"http://insecure.example.com/catalog.json",
|
||||
)
|
||||
|
||||
result = self._invoke(
|
||||
["integration", "info", "definitely-not-real"], project
|
||||
)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "SPECKIT_INTEGRATION_CATALOG_URL" in normalized_output
|
||||
assert "unset it to use the configured catalog files" in normalized_output
|
||||
assert "Try again when online" not in normalized_output
|
||||
|
||||
# -- catalog list / add / remove ---------------------------------------
|
||||
|
||||
def test_catalog_list_shows_builtin_defaults(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Integration Catalog Sources" in result.output
|
||||
assert "No project-level catalog sources configured" in result.output
|
||||
assert "Active catalog sources" in result.output
|
||||
assert "non-removable" in result.output
|
||||
assert "default" in result.output
|
||||
assert "community" in result.output
|
||||
# Built-in defaults are active, but not removable project entries.
|
||||
assert "[0]" not in result.output
|
||||
assert "[1]" not in result.output
|
||||
|
||||
def test_catalog_add_then_remove_roundtrip(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
|
||||
add_result = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"https://new.example.com/catalog.json",
|
||||
"--name",
|
||||
"mine",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert add_result.exit_code == 0, add_result.output
|
||||
assert "Catalog source added" in add_result.output
|
||||
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
assert cfg_path.exists()
|
||||
|
||||
list_result = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert list_result.exit_code == 0, list_result.output
|
||||
assert "Project catalog sources" in list_result.output
|
||||
assert "[0]" in list_result.output
|
||||
assert "mine" in list_result.output
|
||||
assert "default" not in list_result.output
|
||||
assert "community" not in list_result.output
|
||||
|
||||
remove_result = self._invoke(
|
||||
["integration", "catalog", "remove", "0"], project
|
||||
)
|
||||
assert remove_result.exit_code == 0, remove_result.output
|
||||
assert "'mine' removed" in remove_result.output
|
||||
|
||||
def test_catalog_list_normalizes_blank_project_catalog_names(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://null-name.example.com/catalog.json",
|
||||
"name": None,
|
||||
},
|
||||
{
|
||||
"url": "https://blank-name.example.com/catalog.json",
|
||||
"name": " ",
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "[0] catalog-1" in normalized_output
|
||||
assert "[1] catalog-2" in normalized_output
|
||||
assert "None" not in normalized_output
|
||||
|
||||
def test_catalog_list_env_override_supersedes_project_config(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CATALOG_URL",
|
||||
"https://env.example.com/catalog.json",
|
||||
)
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://project.example.com/catalog.json",
|
||||
"name": "project",
|
||||
"priority": 1,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self._invoke(["integration", "catalog", "list"], project)
|
||||
normalized_output = _normalize_cli_output(result.output)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "SPECKIT_INTEGRATION_CATALOG_URL is set" in normalized_output
|
||||
assert "supersedes configured catalog files" in normalized_output
|
||||
assert "non-removable" in normalized_output
|
||||
assert "https://env.example.com/catalog.json" in normalized_output
|
||||
assert "https://project.example.com/catalog.json" not in normalized_output
|
||||
assert "[0]" not in normalized_output
|
||||
|
||||
def test_catalog_add_strips_whitespace_in_success_output_and_storage(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Surrounding whitespace in the URL must not appear in the success
|
||||
message or be persisted to the YAML config."""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
|
||||
padded_url = " https://padded.example.com/catalog.json "
|
||||
clean_url = "https://padded.example.com/catalog.json"
|
||||
|
||||
add_result = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
padded_url,
|
||||
"--name",
|
||||
"padded",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert add_result.exit_code == 0, add_result.output
|
||||
assert clean_url in add_result.output
|
||||
assert padded_url not in add_result.output
|
||||
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
import yaml as _yaml
|
||||
data = _yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
urls = [c["url"] for c in data["catalogs"]]
|
||||
assert clean_url in urls
|
||||
assert padded_url not in urls
|
||||
|
||||
def test_catalog_add_rejects_invalid_url(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
result = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"http://insecure.example.com/catalog.json",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "HTTPS" in result.output
|
||||
|
||||
def test_catalog_add_rejects_duplicate(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
url = "https://dup.example.com/catalog.json"
|
||||
first = self._invoke(
|
||||
["integration", "catalog", "add", url], project
|
||||
)
|
||||
assert first.exit_code == 0, first.output
|
||||
second = self._invoke(
|
||||
["integration", "catalog", "add", url], project
|
||||
)
|
||||
assert second.exit_code == 1
|
||||
assert "already configured" in second.output
|
||||
|
||||
def test_catalog_remove_out_of_range(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
# Need a config file for remove to attempt an index lookup
|
||||
self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"https://only.example.com/catalog.json",
|
||||
],
|
||||
project,
|
||||
)
|
||||
result = self._invoke(
|
||||
["integration", "catalog", "remove", "9"], project
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "out of range" in result.output
|
||||
|
||||
def test_catalog_remove_without_config(self, tmp_path, monkeypatch):
|
||||
project = self._make_project(tmp_path)
|
||||
result = self._invoke(
|
||||
["integration", "catalog", "remove", "0"], project
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "No catalog config" in result.output
|
||||
|
||||
def test_catalog_remove_final_entry_restores_defaults(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""End-to-end: add → remove-last-entry → list should not error.
|
||||
|
||||
Regression for the flow where a user adds a catalog, removes it, then
|
||||
runs any follow-up integration command. Without the fix the config
|
||||
file would be left as `catalogs: []` and every subsequent
|
||||
`integration` call would fail with "contains no 'catalogs' entries".
|
||||
"""
|
||||
project = self._make_project(tmp_path)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
|
||||
add = self._invoke(
|
||||
[
|
||||
"integration",
|
||||
"catalog",
|
||||
"add",
|
||||
"https://only.example.com/catalog.json",
|
||||
"--name",
|
||||
"only",
|
||||
],
|
||||
project,
|
||||
)
|
||||
assert add.exit_code == 0, add.output
|
||||
|
||||
remove = self._invoke(
|
||||
["integration", "catalog", "remove", "0"], project
|
||||
)
|
||||
assert remove.exit_code == 0, remove.output
|
||||
assert "'only' removed" in remove.output
|
||||
|
||||
cfg_path = project / ".specify" / "integration-catalogs.yml"
|
||||
assert not cfg_path.exists(), (
|
||||
"config file should be deleted when the final catalog is removed"
|
||||
)
|
||||
|
||||
# Follow-up command must succeed and show the built-in defaults,
|
||||
# not error out on "contains no 'catalogs' entries".
|
||||
listing = self._invoke(["integration", "catalog", "list"], project)
|
||||
assert listing.exit_code == 0, listing.output
|
||||
assert "default" in listing.output
|
||||
assert "community" in listing.output
|
||||
|
||||
@@ -12,6 +12,7 @@ from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalogError,
|
||||
IntegrationDescriptor,
|
||||
IntegrationDescriptorError,
|
||||
IntegrationValidationError,
|
||||
)
|
||||
|
||||
|
||||
@@ -115,8 +116,45 @@ class TestActiveCatalogs:
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({"catalogs": []}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"):
|
||||
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries") as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg) in str(exc_info.value)
|
||||
|
||||
def test_empty_config_file_raises_no_catalogs(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text("", encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="no 'catalogs' entries"
|
||||
) as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg) in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_load_catalog_config_rejects_falsy_non_mapping_roots(
|
||||
self, tmp_path, monkeypatch, config_content
|
||||
):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(config_content, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="expected a YAML mapping at the root",
|
||||
) as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg) in str(exc_info.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -654,3 +692,838 @@ class TestIntegrationUpgrade:
|
||||
os.chdir(old)
|
||||
assert result.exit_code == 0
|
||||
assert "Nothing to upgrade" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalog — catalog source management (get_catalog_configs / add / remove)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCatalogSourceManagement:
|
||||
"""Unit tests for add_catalog / remove_catalog / get_catalog_configs."""
|
||||
|
||||
def _isolate(self, tmp_path, monkeypatch):
|
||||
"""Point HOME at tmp_path and clear the env override so we read built-ins."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
|
||||
def test_get_catalog_configs_returns_builtin_stack(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
configs = cat.get_catalog_configs()
|
||||
assert [c["name"] for c in configs] == ["default", "community"]
|
||||
assert all(isinstance(c["url"], str) and c["url"] for c in configs)
|
||||
assert configs[0]["install_allowed"] is True
|
||||
assert configs[1]["install_allowed"] is False
|
||||
|
||||
def test_add_catalog_creates_config_file(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://new.example.com/catalog.json", name="mine")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
assert cfg_path.exists()
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"] == [
|
||||
{
|
||||
"name": "mine",
|
||||
"url": "https://new.example.com/catalog.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
"description": "",
|
||||
}
|
||||
]
|
||||
# Round-trip: active catalogs should now come from the config file.
|
||||
active = cat.get_active_catalogs()
|
||||
assert [e.name for e in active] == ["mine"]
|
||||
|
||||
def test_add_catalog_recovers_from_empty_config_file(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text("", encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://example.com/catalog.json")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"] == [
|
||||
{
|
||||
"name": "catalog-1",
|
||||
"url": "https://example.com/catalog.json",
|
||||
"priority": 1,
|
||||
"install_allowed": True,
|
||||
"description": "",
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_add_catalog_rejects_falsy_non_mapping_config_roots(
|
||||
self, tmp_path, monkeypatch, config_content
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(config_content, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="corrupted.*expected a mapping",
|
||||
) as exc_info:
|
||||
cat.add_catalog("https://example.com/catalog.json")
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_add_catalog_auto_derives_name_and_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json")
|
||||
cat.add_catalog("https://b.example.com/catalog.json")
|
||||
|
||||
data = yaml.safe_load(
|
||||
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
|
||||
)
|
||||
entries = data["catalogs"]
|
||||
assert [e["name"] for e in entries] == ["catalog-1", "catalog-2"]
|
||||
assert [e["priority"] for e in entries] == [1, 2]
|
||||
|
||||
def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name=" mine ")
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name=" ")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
entries = data["catalogs"]
|
||||
assert [e["name"] for e in entries] == ["mine", "catalog-2"]
|
||||
|
||||
def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://dup.example.com/catalog.json")
|
||||
with pytest.raises(IntegrationValidationError, match="already configured"):
|
||||
cat.add_catalog("https://dup.example.com/catalog.json")
|
||||
|
||||
def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationCatalogError, match="HTTPS"):
|
||||
cat.add_catalog("http://insecure.example.com/catalog.json")
|
||||
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
|
||||
|
||||
def test_add_catalog_rejects_empty_url(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError, match="must be non-empty"):
|
||||
cat.add_catalog(" ")
|
||||
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
|
||||
|
||||
def test_remove_catalog_without_config_errors(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError, match="No catalog config"):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_happy_path(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name="a")
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name="b")
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "a"
|
||||
|
||||
data = yaml.safe_load(
|
||||
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert [e["name"] for e in data["catalogs"]] == ["b"]
|
||||
|
||||
def test_remove_catalog_index_out_of_range(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name="a")
|
||||
with pytest.raises(IntegrationValidationError, match="out of range"):
|
||||
cat.remove_catalog(5)
|
||||
with pytest.raises(IntegrationValidationError, match="out of range"):
|
||||
cat.remove_catalog(-1)
|
||||
|
||||
def test_corrupt_config_rejected_on_add(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text("- just\n- a\n- list\n", encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError, match="corrupted") as exc_info:
|
||||
cat.add_catalog("https://new.example.com/catalog.json")
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_add_catalog_rejects_non_list_catalogs_with_config_path(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="invalid 'catalogs' value"
|
||||
) as exc_info:
|
||||
cat.add_catalog("https://new.example.com/catalog.json")
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_add_catalog_rejects_non_mapping_entry_with_config_path(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": ["not-a-mapping"]}), encoding="utf-8"
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Invalid catalog entry at index 0"
|
||||
) as exc_info:
|
||||
cat.add_catalog("https://new.example.com/catalog.json")
|
||||
message = str(exc_info.value)
|
||||
assert str(cfg_path) in message
|
||||
assert "expected a mapping" in message
|
||||
|
||||
def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": " ", "name": "blank", "priority": 99},
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": 5,
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name="b")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][-1]["name"] == "b"
|
||||
assert data["catalogs"][-1]["priority"] == 6
|
||||
|
||||
def test_add_catalog_default_name_ignores_blank_url_entries(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": [{"url": " ", "name": "blank"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://example.com/catalog.json")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][-1]["name"] == "catalog-1"
|
||||
|
||||
def test_add_catalog_rejects_non_integer_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": "first",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="'priority' must be an integer, got 'first'",
|
||||
):
|
||||
cat.add_catalog("https://b.example.com/catalog.json")
|
||||
|
||||
def test_add_catalog_accepts_numeric_string_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": "10",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://b.example.com/catalog.json", name="b")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][-1]["name"] == "b"
|
||||
assert data["catalogs"][-1]["priority"] == 11
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bad_url", "reason"),
|
||||
[
|
||||
("http://insecure.example.com/catalog.json", "HTTPS"),
|
||||
(123, "HTTPS"),
|
||||
],
|
||||
)
|
||||
def test_add_catalog_rejects_existing_entry_with_bad_url(
|
||||
self, tmp_path, monkeypatch, bad_url, reason
|
||||
):
|
||||
"""A sibling entry with an http:// URL should block a new add."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": bad_url,
|
||||
"name": "bad",
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(IntegrationValidationError) as exc_info:
|
||||
cat.add_catalog("https://good.example.com/catalog.json")
|
||||
message = str(exc_info.value)
|
||||
assert str(cfg_path) in message
|
||||
assert "index 0" in message
|
||||
assert reason in message
|
||||
|
||||
def test_add_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
|
||||
"""Invalid YAML on disk surfaces as IntegrationValidationError, not a raw YAMLError."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
|
||||
cfg_path.write_text(invalid_yaml, encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Failed to read catalog config"
|
||||
):
|
||||
cat.add_catalog("https://b.example.com/catalog.json")
|
||||
|
||||
def test_remove_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
|
||||
"""Invalid YAML on disk surfaces as IntegrationValidationError from remove_catalog too."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
|
||||
cfg_path.write_text(invalid_yaml, encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Failed to read catalog config"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_add_catalog_defaults_missing_priority_to_index_plus_one(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Existing entries without `priority` should be treated as idx + 1.
|
||||
|
||||
Matches the rule in `_load_catalog_config()`: a valid catalog entry
|
||||
without an explicit `priority` sorts at `idx + 1`, so the new entry
|
||||
should get `max(...) + 1` from those derived values.
|
||||
"""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
# No explicit priority → should be treated as 1
|
||||
{"url": "https://a.example.com/cat.json", "name": "a"},
|
||||
# No explicit priority → should be treated as 2
|
||||
{"url": "https://b.example.com/cat.json", "name": "b"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://c.example.com/cat.json", name="c")
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
new_entry = data["catalogs"][-1]
|
||||
assert new_entry["name"] == "c"
|
||||
# max(implicit [1, 2]) + 1 == 3
|
||||
assert new_entry["priority"] == 3
|
||||
|
||||
def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch):
|
||||
"""Whitespace around the incoming URL should be normalized before write."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog(" https://a.example.com/catalog.json\n", name="a")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json"
|
||||
|
||||
def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch):
|
||||
"""A second add with only whitespace differences must be rejected as a duplicate."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://a.example.com/catalog.json", name="a")
|
||||
with pytest.raises(IntegrationValidationError, match="already configured"):
|
||||
cat.add_catalog(" https://a.example.com/catalog.json ")
|
||||
|
||||
def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch):
|
||||
"""An OSError from `Path.unlink` surfaces as IntegrationValidationError."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://only.example.com/catalog.json", name="only")
|
||||
|
||||
from pathlib import Path as _Path
|
||||
|
||||
def boom(self, *args, **kwargs):
|
||||
raise OSError("simulated unlink failure")
|
||||
|
||||
monkeypatch.setattr(_Path, "unlink", boom)
|
||||
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Failed to delete catalog config"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_ignores_missing_final_config_during_unlink(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://only.example.com/catalog.json", name="only")
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
|
||||
from pathlib import Path as _Path
|
||||
|
||||
original_unlink = _Path.unlink
|
||||
|
||||
def delete_first_then_unlink(self, *args, **kwargs):
|
||||
if self == cfg_path and self.exists():
|
||||
original_unlink(self)
|
||||
return original_unlink(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_Path, "unlink", delete_first_then_unlink)
|
||||
|
||||
assert cat.remove_catalog(0) == "only"
|
||||
assert not cfg_path.exists()
|
||||
|
||||
def test_remove_catalog_empty_list_gives_clear_error(self, tmp_path, monkeypatch):
|
||||
"""Hand-edited empty `catalogs:` produces a clear error, not '0--1'."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(yaml.dump({"catalogs": []}), encoding="utf-8")
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="contains no catalog entries"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_empty_config_file_gives_clear_error(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text("", encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="contains no catalog entries"
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_rejects_non_list_catalogs_with_config_path(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="invalid 'catalogs' value"
|
||||
) as exc_info:
|
||||
cat.remove_catalog(0)
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_remove_catalog_rejects_falsy_non_mapping_config_roots(
|
||||
self, tmp_path, monkeypatch, config_content
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(config_content, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="corrupted.*expected a mapping",
|
||||
) as exc_info:
|
||||
cat.remove_catalog(0)
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
def test_remove_last_catalog_deletes_file_and_restores_defaults(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Removing the final catalog must not leave behind `catalogs: []`.
|
||||
|
||||
`_load_catalog_config` treats an empty `catalogs` list as an error,
|
||||
so writing that file would break every subsequent `integration`
|
||||
command. Removing the last entry should delete the config file so the
|
||||
project falls back to built-in defaults.
|
||||
"""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
|
||||
cat.add_catalog("https://only.example.com/catalog.json", name="only")
|
||||
assert cfg_path.exists()
|
||||
assert [e.name for e in cat.get_active_catalogs()] == ["only"]
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "only"
|
||||
|
||||
assert not cfg_path.exists(), (
|
||||
"remove_catalog should delete the config file when emptying it"
|
||||
)
|
||||
# Follow-up loads fall back to built-in defaults, not an error.
|
||||
active = cat.get_active_catalogs()
|
||||
assert [e.name for e in active] == ["default", "community"]
|
||||
|
||||
def test_load_catalog_config_raises_validation_error_for_invalid_yaml(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Local-config problems must surface as IntegrationValidationError so
|
||||
CLI handlers can route them to local-config (not network) guidance."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
invalid_yaml = "catalogs:\n - [bad\n"
|
||||
cfg_path.write_text(invalid_yaml, encoding="utf-8")
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
# Subclass match: IntegrationValidationError (specifically), not the
|
||||
# bare IntegrationCatalogError parent that callers used previously.
|
||||
with pytest.raises(IntegrationValidationError, match="Failed to read catalog config"):
|
||||
cat.get_active_catalogs()
|
||||
|
||||
def test_load_catalog_config_rejects_boolean_priority(self, tmp_path, monkeypatch):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": "https://a.example.com/catalog.json",
|
||||
"name": "a",
|
||||
"priority": True,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
with pytest.raises(
|
||||
IntegrationValidationError, match="Invalid priority|expected integer"
|
||||
) as exc_info:
|
||||
cat.get_active_catalogs()
|
||||
assert str(cfg_path) in str(exc_info.value)
|
||||
|
||||
@pytest.mark.parametrize("raw_name", [None, " "])
|
||||
def test_load_catalog_config_defaults_blank_names(
|
||||
self, tmp_path, monkeypatch, raw_name
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{
|
||||
"url": " ",
|
||||
"name": "skipped",
|
||||
},
|
||||
{
|
||||
"url": "https://example.com/catalog.json",
|
||||
"name": raw_name,
|
||||
}
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
assert [entry.name for entry in cat.get_active_catalogs()] == ["catalog-1"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw_name", "expected"),
|
||||
[
|
||||
(None, "https://one.example.com/c.json"),
|
||||
(" ", "https://one.example.com/c.json"),
|
||||
(123, "123"),
|
||||
],
|
||||
)
|
||||
def test_remove_catalog_normalizes_removed_display_name(
|
||||
self, tmp_path, monkeypatch, raw_name, expected
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
cat.add_catalog("https://one.example.com/c.json", name="one")
|
||||
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
data["catalogs"][0]["name"] = raw_name
|
||||
cfg_path.write_text(yaml.dump(data), encoding="utf-8")
|
||||
|
||||
assert cat.remove_catalog(0) == expected
|
||||
|
||||
def test_remove_catalog_uses_display_order_with_explicit_priorities(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""`remove_catalog(index)` must remove the entry shown at that index by
|
||||
`catalog list`, not the entry at that raw YAML position."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
# YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15).
|
||||
# Display (sorted by priority asc): beta (10), gamma (15), alpha (20).
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20},
|
||||
{"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10},
|
||||
{"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0).
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "beta"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
remaining_names = [c["name"] for c in data["catalogs"]]
|
||||
# YAML order is preserved for the survivors; only beta is gone.
|
||||
assert remaining_names == ["alpha", "gamma"]
|
||||
|
||||
def test_remove_catalog_display_order_with_missing_priorities(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Entries without `priority` default to `idx + 1` (matching
|
||||
`_load_catalog_config`), so display order tracks YAML order and the
|
||||
first display entry is the first YAML entry."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
{"url": "https://two.example.com/c.json", "name": "two"},
|
||||
{"url": "https://three.example.com/c.json", "name": "three"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Implicit priorities: one=1, two=2, three=3 → display order matches YAML.
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "one"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["two", "three"]
|
||||
|
||||
def test_remove_catalog_bool_priority_falls_back_to_yaml_index(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
{
|
||||
"url": "https://bool.example.com/c.json",
|
||||
"name": "bool",
|
||||
"priority": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
|
||||
assert removed == "one"
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["bool"]
|
||||
|
||||
def test_remove_catalog_display_order_skips_blank_url_entries(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Blank-url entries are not shown by catalog list, so remove skips them too."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": " ", "name": "blank", "priority": 0},
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
{"url": "https://two.example.com/c.json", "name": "two"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "one"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["blank", "two"]
|
||||
|
||||
def test_remove_catalog_deletes_file_when_only_skipped_entries_remain(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": " ", "name": "blank", "priority": 0},
|
||||
{"url": "https://one.example.com/c.json", "name": "one"},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "one"
|
||||
assert not cfg_path.exists()
|
||||
|
||||
active = cat.get_active_catalogs()
|
||||
assert [e.name for e in active] == ["default", "community"]
|
||||
|
||||
def test_remove_catalog_allows_numeric_url_entry_cleanup(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump({"catalogs": [{"name": "numeric-url", "url": 123}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
|
||||
assert removed == "numeric-url"
|
||||
assert not cfg_path.exists()
|
||||
|
||||
def test_remove_catalog_errors_when_no_entries_are_removable(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "", "name": "empty"},
|
||||
{"name": "missing"},
|
||||
"not-a-mapping",
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
with pytest.raises(
|
||||
IntegrationValidationError,
|
||||
match="no removable catalog entries",
|
||||
):
|
||||
cat.remove_catalog(0)
|
||||
|
||||
def test_remove_catalog_display_order_mixes_explicit_and_default(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""An explicit low priority should sort ahead of default-priority
|
||||
siblings, even if it appears later in the YAML."""
|
||||
self._isolate(tmp_path, monkeypatch)
|
||||
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b.
|
||||
# The blank name should fall back to the removed URL, not raw YAML idx.
|
||||
cfg_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"catalogs": [
|
||||
{"url": "https://a.example.com/c.json", "name": "a"},
|
||||
{"url": "https://b.example.com/c.json", "name": "b"},
|
||||
{
|
||||
"url": "https://c.example.com/c.json",
|
||||
"name": " ",
|
||||
"priority": 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
removed = cat.remove_catalog(0)
|
||||
assert removed == "https://c.example.com/c.json"
|
||||
|
||||
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
|
||||
assert [c["name"] for c in data["catalogs"]] == ["a", "b"]
|
||||
|
||||
Reference in New Issue
Block a user