generated from coulomb/repo-seed
Implements the full BRIDGE-WP-0003 workplan: 188 tests passing, 0 lint errors. ## What's added **Capability registry** (`src/bridge/capabilities.py`): - 10 capabilities with required_access_modes (cli/mcp/skill) - Single source of truth for what OpsBridge does and where **MCP server** (`src/bridge/mcp_server/server.py`): - 10 FastMCP tools: bridge_up/down/restart/status/logs + 5 catalog_* tools - 3 resources: bridge://status, catalog://domains, catalog://targets - `.mcp.json` for project-scope auto-registration - `scripts/register_mcp.py` for user-scope machine-global registration **Skill** (`~/.claude/plugins/ops-bridge/bridge-status.md`): - /bridge-status: health table with emoji indicators + remediation advice **Cross-mode test coverage enforcement**: - `tests/conftest.py`: capability/access_mode marks + collect_capability_coverage() - `tests/test_mcp.py`: 31 FastMCP in-process client tests (Client(mcp) pattern) - `tests/test_skill.py`: static skill lint against capability registry - `tests/test_coverage_completeness.py`: meta-test that fails if any required (capability × mode) pair lacks a test; also validates CLI commands and MCP tools are registered in the capability registry **ADR** (`architecture/adr-001-cross-mode-capability-registry.md`): - Documents the registry pattern and FastMCP 3.x testing approach Key implementation note: FastMCP 3.x in-process results are in result.content[0].text (JSON string), not result.data directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
141 lines
4.6 KiB
Python
141 lines
4.6 KiB
Python
"""Tests for catalog loader."""
|
|
import textwrap
|
|
|
|
import pytest
|
|
|
|
from bridge.catalog.loader import CatalogLoadError, load_catalog
|
|
from bridge.catalog.models import Catalog
|
|
|
|
|
|
@pytest.fixture
|
|
def catalog_dir(tmp_path):
|
|
"""Build a minimal valid catalog fixture."""
|
|
root = tmp_path / "opscatalog"
|
|
domain_dir = root / "domains" / "coulombcore"
|
|
(domain_dir / "targets").mkdir(parents=True)
|
|
(domain_dir / "bridges").mkdir(parents=True)
|
|
(domain_dir / "docs").mkdir(parents=True)
|
|
actors_dir = root / "actors"
|
|
actors_dir.mkdir(parents=True)
|
|
|
|
(domain_dir / "domain.yaml").write_text(textwrap.dedent("""\
|
|
type: domain
|
|
id: coulombcore
|
|
name: CoulombCore Infrastructure
|
|
description: Core infra
|
|
environment: production
|
|
"""))
|
|
|
|
(domain_dir / "targets" / "state-hub.yaml").write_text(textwrap.dedent("""\
|
|
type: target
|
|
id: state-hub
|
|
domain: coulombcore
|
|
kind: service
|
|
description: State coordination service
|
|
reachable_via:
|
|
- state-hub-coulombcore
|
|
"""))
|
|
|
|
(domain_dir / "bridges" / "state-hub-coulombcore.yaml").write_text(textwrap.dedent("""\
|
|
type: bridge
|
|
id: state-hub-coulombcore
|
|
domain: coulombcore
|
|
target: state-hub
|
|
description: Ops bridge
|
|
access_method: ssh-reverse
|
|
host: coulombcore.local
|
|
remote_port: 18000
|
|
local_port: 8000
|
|
ssh_user: ubuntu
|
|
ssh_key: ~/.ssh/id_ops
|
|
actor: agent.claude-coulombcore
|
|
health_check:
|
|
url: http://127.0.0.1:18000/health
|
|
interval_seconds: 30
|
|
timeout_seconds: 5
|
|
reconnect:
|
|
max_attempts: 0
|
|
backoff_initial: 5
|
|
backoff_max: 60
|
|
"""))
|
|
|
|
(actors_dir / "agents.yaml").write_text(textwrap.dedent("""\
|
|
type: actor
|
|
id: agent.claude-coulombcore
|
|
class: automation
|
|
description: Claude Code agent on CoulombCore
|
|
"""))
|
|
|
|
(domain_dir / "docs" / "overview.md").write_text("# Overview\nSome ops notes.")
|
|
|
|
return root
|
|
|
|
|
|
class TestLoadCatalog:
|
|
def test_loads_domain(self, catalog_dir):
|
|
cat = load_catalog(catalog_dir)
|
|
assert "coulombcore" in cat.domains
|
|
d = cat.domains["coulombcore"]
|
|
assert d.name == "CoulombCore Infrastructure"
|
|
assert d.environment == "production"
|
|
|
|
def test_loads_target(self, catalog_dir):
|
|
cat = load_catalog(catalog_dir)
|
|
assert "state-hub" in cat.targets
|
|
t = cat.targets["state-hub"]
|
|
assert t.domain == "coulombcore"
|
|
assert t.kind == "service"
|
|
assert "state-hub-coulombcore" in t.reachable_via
|
|
|
|
def test_loads_bridge(self, catalog_dir):
|
|
cat = load_catalog(catalog_dir)
|
|
assert "state-hub-coulombcore" in cat.bridges
|
|
b = cat.bridges["state-hub-coulombcore"]
|
|
assert b.host == "coulombcore.local"
|
|
assert b.remote_port == 18000
|
|
assert b.health_check is not None
|
|
assert b.health_check.url == "http://127.0.0.1:18000/health"
|
|
assert b.reconnect is not None
|
|
assert b.reconnect.max_attempts == 0
|
|
|
|
def test_loads_actor(self, catalog_dir):
|
|
cat = load_catalog(catalog_dir)
|
|
assert "agent.claude-coulombcore" in cat.actors
|
|
a = cat.actors["agent.claude-coulombcore"]
|
|
assert a.actor_class == "automation"
|
|
|
|
def test_unknown_type_skipped(self, catalog_dir):
|
|
(catalog_dir / "domains" / "coulombcore" / "unknown.yaml").write_text(
|
|
"type: mystery\nid: x\n"
|
|
)
|
|
# Should not raise
|
|
cat = load_catalog(catalog_dir)
|
|
assert isinstance(cat, Catalog)
|
|
|
|
def test_empty_catalog_dir(self, tmp_path):
|
|
root = tmp_path / "empty"
|
|
root.mkdir()
|
|
cat = load_catalog(root)
|
|
assert cat.domains == {}
|
|
assert cat.bridges == {}
|
|
|
|
def test_missing_required_field_raises(self, tmp_path):
|
|
root = tmp_path / "bad"
|
|
domain_dir = root / "domains" / "x"
|
|
domain_dir.mkdir(parents=True)
|
|
(domain_dir / "domain.yaml").write_text("type: domain\nname: X\n")
|
|
with pytest.raises(CatalogLoadError, match="id"):
|
|
load_catalog(root)
|
|
|
|
def test_nonexistent_path_raises(self, tmp_path):
|
|
with pytest.raises(CatalogLoadError, match="not found"):
|
|
load_catalog(tmp_path / "nonexistent")
|
|
|
|
def test_invalid_yaml_raises(self, tmp_path):
|
|
root = tmp_path / "bad"
|
|
domain_dir = root / "domains" / "x"
|
|
domain_dir.mkdir(parents=True)
|
|
(domain_dir / "domain.yaml").write_text("type: domain\n[\nbad: yaml")
|
|
with pytest.raises(CatalogLoadError):
|
|
load_catalog(root)
|