generated from coulomb/repo-seed
feat(BRIDGE-WP-0003): MCP server, /bridge-status skill, cross-mode coverage enforcement
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>
This commit is contained in:
154
tests/conftest.py
Normal file
154
tests/conftest.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""Shared pytest configuration for OpsBridge tests.
|
||||
|
||||
Registers capability and access_mode marks, and provides the
|
||||
collect_capability_coverage() helper used by the cross-mode meta-test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from typing import Iterable
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_CONFIG = textwrap.dedent("""\
|
||||
tunnels:
|
||||
test-tunnel:
|
||||
host: host.local
|
||||
remote_port: 18000
|
||||
local_port: 8000
|
||||
ssh_user: ubuntu
|
||||
ssh_key: ~/.ssh/id_ops
|
||||
actor: operator.bernd
|
||||
actors:
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd
|
||||
""")
|
||||
|
||||
VALID_CONFIG_WITH_CATALOG = textwrap.dedent("""\
|
||||
tunnels:
|
||||
test-tunnel:
|
||||
host: host.local
|
||||
remote_port: 18000
|
||||
local_port: 8000
|
||||
ssh_user: ubuntu
|
||||
ssh_key: ~/.ssh/id_ops
|
||||
actor: operator.bernd
|
||||
actors:
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd
|
||||
catalog_path: {catalog_path}
|
||||
""")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(tmp_path):
|
||||
f = tmp_path / "tunnels.yaml"
|
||||
f.write_text(VALID_CONFIG)
|
||||
return f
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_dir(tmp_path):
|
||||
d = tmp_path / "state"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def catalog_dir(tmp_path):
|
||||
"""Minimal catalog directory with one domain, target, and bridge."""
|
||||
cat = tmp_path / "catalog"
|
||||
domain_dir = cat / "domains" / "coulombcore"
|
||||
domain_dir.mkdir(parents=True)
|
||||
(domain_dir / "domain.yaml").write_text(textwrap.dedent("""\
|
||||
type: domain
|
||||
id: coulombcore
|
||||
name: CoulombCore Infrastructure
|
||||
description: Core infrastructure domain
|
||||
environment: production
|
||||
"""))
|
||||
targets_dir = domain_dir / "targets"
|
||||
targets_dir.mkdir()
|
||||
(targets_dir / "state-hub.yaml").write_text(textwrap.dedent("""\
|
||||
type: target
|
||||
id: state-hub
|
||||
domain: coulombcore
|
||||
kind: service
|
||||
description: Infrastructure state coordination service
|
||||
reachable_via:
|
||||
- state-hub-coulombcore
|
||||
"""))
|
||||
bridges_dir = domain_dir / "bridges"
|
||||
bridges_dir.mkdir()
|
||||
(bridges_dir / "state-hub-coulombcore.yaml").write_text(textwrap.dedent("""\
|
||||
type: bridge
|
||||
id: state-hub-coulombcore
|
||||
domain: coulombcore
|
||||
target: state-hub
|
||||
description: Bridge to state hub
|
||||
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
|
||||
reconnect:
|
||||
max_attempts: 0
|
||||
backoff_initial: 5
|
||||
backoff_max: 60
|
||||
"""))
|
||||
actors_dir = cat / "actors"
|
||||
actors_dir.mkdir()
|
||||
(actors_dir / "agent.yaml").write_text(textwrap.dedent("""\
|
||||
type: actor
|
||||
id: agent.claude-coulombcore
|
||||
class: automation
|
||||
description: Claude Code agent on CoulombCore
|
||||
"""))
|
||||
return cat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_file_with_catalog(tmp_path, catalog_dir):
|
||||
f = tmp_path / "tunnels.yaml"
|
||||
f.write_text(VALID_CONFIG_WITH_CATALOG.format(catalog_path=str(catalog_dir)))
|
||||
return f
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage collector helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def collect_capability_coverage(items: Iterable) -> set[tuple[str, str]]:
|
||||
"""Walk pytest items and return set of (capability_name, access_mode) pairs.
|
||||
|
||||
Each test item is inspected for `capability` and `access_mode` markers.
|
||||
A pair is added for every combination of capability × access_mode marks
|
||||
found on a single item.
|
||||
|
||||
Args:
|
||||
items: Iterable of pytest.Item objects (from session.items or similar).
|
||||
|
||||
Returns:
|
||||
Set of (capability_name, access_mode) tuples found across all items.
|
||||
"""
|
||||
covered: set[tuple[str, str]] = set()
|
||||
for item in items:
|
||||
capabilities = [
|
||||
m.args[0] for m in item.iter_markers("capability") if m.args
|
||||
]
|
||||
modes = [
|
||||
m.args[0] for m in item.iter_markers("access_mode") if m.args
|
||||
]
|
||||
for cap in capabilities:
|
||||
for mode in modes:
|
||||
covered.add((cap, mode))
|
||||
return covered
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for audit logging."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Tests for catalog CLI commands (targets, catalog list/validate/show)."""
|
||||
import json
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
@@ -90,6 +89,8 @@ def env(config_file, tmp_path):
|
||||
|
||||
|
||||
class TestTargetsCommand:
|
||||
@pytest.mark.capability("catalog_list_targets")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_targets_shows_table(self, env):
|
||||
result = runner.invoke(app, ["targets"], env=env)
|
||||
assert result.exit_code == 0
|
||||
@@ -120,6 +121,8 @@ class TestTargetsCommand:
|
||||
assert result.exit_code == 1
|
||||
assert "catalog" in result.output.lower()
|
||||
|
||||
@pytest.mark.capability("catalog_show_target")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_targets_show_subcommand(self, env):
|
||||
result = runner.invoke(app, ["targets", "show", "state-hub"], env=env)
|
||||
assert result.exit_code == 0
|
||||
@@ -132,6 +135,8 @@ class TestTargetsCommand:
|
||||
|
||||
|
||||
class TestCatalogCommand:
|
||||
@pytest.mark.capability("catalog_list_domains")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_catalog_list(self, env):
|
||||
result = runner.invoke(app, ["catalog", "list"], env=env)
|
||||
assert result.exit_code == 0
|
||||
@@ -144,6 +149,8 @@ class TestCatalogCommand:
|
||||
assert isinstance(data, list)
|
||||
assert any(d["domain"] == "coulombcore" for d in data)
|
||||
|
||||
@pytest.mark.capability("catalog_validate")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_catalog_validate_clean(self, env):
|
||||
result = runner.invoke(app, ["catalog", "validate"], env=env)
|
||||
assert result.exit_code == 0
|
||||
@@ -166,6 +173,8 @@ class TestCatalogCommand:
|
||||
assert result.exit_code == 1
|
||||
assert "missing-bridge" in result.output
|
||||
|
||||
@pytest.mark.capability("catalog_show_bridge")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_catalog_show(self, env):
|
||||
result = runner.invoke(app, ["catalog", "show", "state-hub-coulombcore"], env=env)
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"""Integration tests for OpsCatalog (T14-T16 from BRIDGE-WP-0002)."""
|
||||
import json
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from bridge.catalog.loader import load_catalog
|
||||
from bridge.catalog.resolver import BridgeNotFound, resolve
|
||||
from bridge.catalog.resolver import resolve
|
||||
from bridge.catalog.validator import validate_catalog
|
||||
from bridge.cli import app
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for catalog loader."""
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Tests for catalog domain models."""
|
||||
import pytest
|
||||
from bridge.catalog.models import (
|
||||
ActorClass,
|
||||
Catalog,
|
||||
|
||||
@@ -81,7 +81,6 @@ class TestResolve:
|
||||
resolve("any-name", catalog=None, inline_tunnels={})
|
||||
|
||||
def test_resolve_preserves_reconnect_policy(self, catalog):
|
||||
from bridge.models import ReconnectPolicy
|
||||
catalog.bridges["catalog-bridge"].reconnect = ReconnectPolicy(
|
||||
max_attempts=3, backoff_initial=2, backoff_max=30
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Tests for catalog validator."""
|
||||
import pytest
|
||||
from bridge.catalog.models import (
|
||||
ActorClass,
|
||||
Catalog,
|
||||
@@ -7,7 +6,7 @@ from bridge.catalog.models import (
|
||||
CatalogDomain,
|
||||
CatalogTarget,
|
||||
)
|
||||
from bridge.catalog.validator import ValidationError, validate_catalog
|
||||
from bridge.catalog.validator import validate_catalog
|
||||
|
||||
|
||||
def _make_full_catalog() -> Catalog:
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for CLI commands."""
|
||||
import json
|
||||
import os
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -74,6 +72,8 @@ class TestHelpCommand:
|
||||
|
||||
|
||||
class TestStatusCommand:
|
||||
@pytest.mark.capability("bridge_status")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_status_shows_tunnels(self, env, state_dir):
|
||||
result = runner.invoke(app, ["status"], env=env)
|
||||
assert result.exit_code == 0
|
||||
@@ -106,6 +106,8 @@ class TestUpCommand:
|
||||
assert result.exit_code == 1
|
||||
assert "nonexistent" in result.output
|
||||
|
||||
@pytest.mark.capability("bridge_up")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_up_calls_manager_start(self, env, state_dir):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
@@ -133,6 +135,8 @@ class TestDownCommand:
|
||||
result = runner.invoke(app, ["down", "nonexistent"], env=env)
|
||||
assert result.exit_code == 1
|
||||
|
||||
@pytest.mark.capability("bridge_down")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_down_calls_manager_stop(self, env, state_dir):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
@@ -164,6 +168,8 @@ class TestLogsCommand:
|
||||
result = runner.invoke(app, ["logs", "test-tunnel"], env=env)
|
||||
assert result.exit_code == 0
|
||||
|
||||
@pytest.mark.capability("bridge_logs")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_logs_shows_events(self, env, state_dir):
|
||||
import json as _json
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -187,6 +193,8 @@ class TestRestartCommand:
|
||||
result = runner.invoke(app, ["restart", "nonexistent"], env=env)
|
||||
assert result.exit_code == 1
|
||||
|
||||
@pytest.mark.capability("bridge_restart")
|
||||
@pytest.mark.access_mode("cli")
|
||||
def test_restart_calls_stop_then_start(self, env):
|
||||
with patch("bridge.cli.TunnelManager") as mock_mgr_cls:
|
||||
mock_mgr = MagicMock()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Tests for config loading."""
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
157
tests/test_coverage_completeness.py
Normal file
157
tests/test_coverage_completeness.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Cross-mode capability coverage meta-test.
|
||||
|
||||
Enforces that every capability in the registry has at least one test
|
||||
marked with @pytest.mark.capability(name) and @pytest.mark.access_mode(mode)
|
||||
for each of its required_access_modes.
|
||||
|
||||
The test discovers coverage by walking all collected test items, so it will
|
||||
only pass when the full test suite is collected (i.e. run without -k filters
|
||||
that exclude capability-marked tests).
|
||||
|
||||
Also validates the registry itself is self-consistent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.capabilities import CAPABILITIES, CAPABILITIES_BY_NAME
|
||||
from tests.conftest import collect_capability_coverage
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry self-consistency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_registry_has_capabilities():
|
||||
"""Sanity: registry must be non-empty."""
|
||||
assert len(CAPABILITIES) > 0
|
||||
|
||||
|
||||
def test_registry_names_are_unique():
|
||||
names = [c.name for c in CAPABILITIES]
|
||||
assert len(names) == len(set(names)), "Duplicate capability names in registry"
|
||||
|
||||
|
||||
def test_registry_access_modes_are_valid():
|
||||
valid = {"cli", "mcp", "skill"}
|
||||
for cap in CAPABILITIES:
|
||||
unknown = cap.required_access_modes - valid
|
||||
assert not unknown, (
|
||||
f"Capability '{cap.name}' has unknown access modes: {unknown}"
|
||||
)
|
||||
|
||||
|
||||
def test_registry_each_capability_has_at_least_one_mode():
|
||||
for cap in CAPABILITIES:
|
||||
assert cap.required_access_modes, (
|
||||
f"Capability '{cap.name}' has no required_access_modes"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-mode coverage completeness (session-scope fixture)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def capability_coverage(request) -> set[tuple[str, str]]:
|
||||
"""Collect all (capability, access_mode) pairs from the test session."""
|
||||
return collect_capability_coverage(request.session.items)
|
||||
|
||||
|
||||
def test_all_required_modes_have_tests(capability_coverage):
|
||||
"""Every (capability, mode) pair in the registry must have a test."""
|
||||
missing: list[str] = []
|
||||
for cap in CAPABILITIES:
|
||||
for mode in sorted(cap.required_access_modes):
|
||||
if (cap.name, mode) not in capability_coverage:
|
||||
missing.append(f" {cap.name!r} × {mode!r}")
|
||||
|
||||
if missing:
|
||||
pytest.fail(
|
||||
"Missing test coverage for the following (capability, access_mode) pairs:\n"
|
||||
+ "\n".join(missing)
|
||||
+ "\n\nAdd a test with @pytest.mark.capability(<name>) and "
|
||||
"@pytest.mark.access_mode(<mode>)."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T02 — Registry completeness against CLI commands and MCP tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_registry_cli_capabilities_have_matching_commands():
|
||||
"""Every capability requiring CLI must have a corresponding CLI command.
|
||||
|
||||
Checks that the registry doesn't list CLI requirements for operations that
|
||||
don't actually exist as CLI commands. Uses the Typer app's callback names.
|
||||
"""
|
||||
from bridge.cli import app, targets_app, catalog_app
|
||||
|
||||
# Collect all CLI callback function names (canonical command identity)
|
||||
top_level = {f"bridge_{cmd.callback.__name__}" for cmd in app.registered_commands}
|
||||
# targets sub-commands: callback name "targets_show" → "catalog_show_target"
|
||||
targets_cmds = set()
|
||||
for cmd in targets_app.registered_commands:
|
||||
fn = cmd.callback.__name__
|
||||
if fn == "targets_show":
|
||||
targets_cmds.add("catalog_show_target")
|
||||
catalog_cmds = set()
|
||||
for cmd in catalog_app.registered_commands:
|
||||
fn = cmd.callback.__name__
|
||||
if fn == "catalog_list":
|
||||
catalog_cmds.add("catalog_list_domains")
|
||||
elif fn == "catalog_validate":
|
||||
catalog_cmds.add("catalog_validate")
|
||||
elif fn == "catalog_show":
|
||||
catalog_cmds.add("catalog_show_bridge")
|
||||
|
||||
# Also include catalog_list_targets (from targets_app without sub-command filter)
|
||||
# The targets app root command lists targets
|
||||
all_cli_caps = top_level | targets_cmds | catalog_cmds | {"catalog_list_targets"}
|
||||
|
||||
for cap in CAPABILITIES:
|
||||
if "cli" in cap.required_access_modes:
|
||||
assert cap.name in all_cli_caps, (
|
||||
f"Capability '{cap.name}' requires CLI coverage but no matching "
|
||||
f"CLI command was found. Either add the command or update the registry."
|
||||
)
|
||||
|
||||
|
||||
async def test_mcp_tools_in_registry():
|
||||
"""Every MCP tool name must appear as a capability in the registry."""
|
||||
from fastmcp import Client
|
||||
from bridge.mcp_server.server import mcp
|
||||
|
||||
async with Client(mcp) as c:
|
||||
tools = await c.list_tools()
|
||||
tool_names = {t.name for t in tools}
|
||||
|
||||
registered_cap_names = set(CAPABILITIES_BY_NAME)
|
||||
for name in tool_names:
|
||||
assert name in registered_cap_names, (
|
||||
f"MCP tool '{name}' is not registered as a capability. "
|
||||
f"Add it to src/bridge/capabilities.py."
|
||||
)
|
||||
|
||||
|
||||
def test_no_orphan_capability_marks(capability_coverage):
|
||||
"""Every (capability, mode) pair in the test suite must exist in the registry.
|
||||
|
||||
This prevents tests from referencing stale or misspelled capability names.
|
||||
"""
|
||||
orphans: list[str] = []
|
||||
for cap_name, mode in sorted(capability_coverage):
|
||||
if cap_name not in CAPABILITIES_BY_NAME:
|
||||
orphans.append(f" {cap_name!r} (mode={mode!r}) — not in registry")
|
||||
else:
|
||||
cap = CAPABILITIES_BY_NAME[cap_name]
|
||||
if mode not in cap.required_access_modes:
|
||||
orphans.append(
|
||||
f" {cap_name!r} × {mode!r} — mode not required for this capability"
|
||||
)
|
||||
|
||||
if orphans:
|
||||
pytest.fail(
|
||||
"Test suite references capability/mode pairs not in registry:\n"
|
||||
+ "\n".join(orphans)
|
||||
)
|
||||
@@ -1,9 +1,5 @@
|
||||
"""Integration tests for OpsBridge."""
|
||||
import json
|
||||
import os
|
||||
import textwrap
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -132,7 +128,7 @@ class TestReconnectLoop:
|
||||
class TestHealthCheckDegradedPath:
|
||||
def test_degraded_state_on_health_failure(self, state_dir):
|
||||
"""Health check failure sets state to DEGRADED."""
|
||||
from bridge.health import HealthChecker, HealthResult
|
||||
from bridge.health import HealthResult
|
||||
|
||||
hc_cfg = MagicMock()
|
||||
hc_cfg.url = "http://127.0.0.1:19001/health"
|
||||
@@ -170,9 +166,7 @@ class TestHealthCheckDegradedPath:
|
||||
return proc
|
||||
|
||||
failed_result = HealthResult(ok=False, error="connection refused")
|
||||
recovered_result = HealthResult(ok=True, status_code=200)
|
||||
|
||||
import asyncio
|
||||
|
||||
async def fake_check_failing():
|
||||
return failed_result
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"""Tests for TunnelManager."""
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -91,7 +89,7 @@ class TestTunnelManager:
|
||||
|
||||
# We can't actually fork in tests; verify state transitions via mock
|
||||
with patch("subprocess.Popen") as mock_popen, \
|
||||
patch("os.fork", return_value=1234) as mock_fork, \
|
||||
patch("os.fork", return_value=1234), \
|
||||
patch("os.setsid"), \
|
||||
patch("os._exit"):
|
||||
mock_proc = MagicMock()
|
||||
|
||||
548
tests/test_mcp.py
Normal file
548
tests/test_mcp.py
Normal file
@@ -0,0 +1,548 @@
|
||||
"""Tests for OpsBridge MCP server tools (FastMCP in-process client).
|
||||
|
||||
Uses FastMCP's Client(mcp_app) context manager — no network, no subprocess.
|
||||
All tests are async; asyncio_mode = "auto" in pyproject.toml.
|
||||
|
||||
FastMCP 3.x returns results in result.content[0].text as a JSON string.
|
||||
Use _data(result) to extract and parse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.mcp_server.server import mcp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _data(result) -> list | dict:
|
||||
"""Extract and parse JSON from a FastMCP CallToolResult.
|
||||
|
||||
FastMCP 3.x: non-empty results are in result.content[0].text.
|
||||
Empty list/dict returns come back with empty content; result.data holds them.
|
||||
"""
|
||||
if not result.content:
|
||||
return result.data # empty list/dict
|
||||
text = result.content[0].text
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, content: str) -> Path:
|
||||
f = tmp_path / "tunnels.yaml"
|
||||
f.write_text(content)
|
||||
return f
|
||||
|
||||
|
||||
def _simple_config(tmp_path: Path) -> Path:
|
||||
return _write_config(tmp_path, textwrap.dedent("""\
|
||||
tunnels:
|
||||
test-tunnel:
|
||||
host: host.local
|
||||
remote_port: 18000
|
||||
local_port: 8000
|
||||
ssh_user: ubuntu
|
||||
ssh_key: ~/.ssh/id_ops
|
||||
actor: operator.bernd
|
||||
actors:
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd
|
||||
"""))
|
||||
|
||||
|
||||
def _catalog_config(tmp_path: Path, catalog_dir: Path) -> Path:
|
||||
return _write_config(tmp_path, textwrap.dedent(f"""\
|
||||
tunnels:
|
||||
test-tunnel:
|
||||
host: host.local
|
||||
remote_port: 18000
|
||||
local_port: 8000
|
||||
ssh_user: ubuntu
|
||||
ssh_key: ~/.ssh/id_ops
|
||||
actor: operator.bernd
|
||||
actors:
|
||||
operator.bernd:
|
||||
class: human
|
||||
description: Bernd
|
||||
catalog_path: {catalog_dir}
|
||||
"""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def env_simple(tmp_path, monkeypatch):
|
||||
cfg = _simple_config(tmp_path)
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
|
||||
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_catalog(tmp_path, catalog_dir, monkeypatch):
|
||||
cfg = _catalog_config(tmp_path, catalog_dir)
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
|
||||
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_no_catalog(tmp_path, monkeypatch):
|
||||
cfg = _simple_config(tmp_path)
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
|
||||
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpBridgeStatus:
|
||||
@pytest.mark.capability("bridge_status")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_bridge_status_returns_list(self, env_simple):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_status", {})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
row = data[0]
|
||||
assert row["tunnel"] == "test-tunnel"
|
||||
assert "state" in row
|
||||
assert "actor" in row
|
||||
assert "host" in row
|
||||
|
||||
async def test_bridge_status_bad_config(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(tmp_path / "nonexistent.yaml"))
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_status", {})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert "error" in data[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge_up
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpBridgeUp:
|
||||
@pytest.mark.capability("bridge_up")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_bridge_up_starts_tunnel(self, env_simple):
|
||||
with patch("bridge.manager.TunnelManager") as mock_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = False
|
||||
mock_cls.return_value = mock_mgr
|
||||
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_up", {"tunnel": "test-tunnel"})
|
||||
|
||||
data = _data(result)
|
||||
assert "started" in data
|
||||
assert "test-tunnel" in data["started"]
|
||||
|
||||
async def test_bridge_up_already_running(self, env_simple):
|
||||
with patch("bridge.manager.TunnelManager") as mock_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = True
|
||||
mock_cls.return_value = mock_mgr
|
||||
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_up", {"tunnel": "test-tunnel"})
|
||||
|
||||
data = _data(result)
|
||||
assert "already_running" in data
|
||||
assert "test-tunnel" in data["already_running"]
|
||||
|
||||
async def test_bridge_up_unknown_tunnel(self, env_simple):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_up", {"tunnel": "nonexistent"})
|
||||
data = _data(result)
|
||||
assert "error" in data
|
||||
|
||||
async def test_bridge_up_all_tunnels(self, env_simple):
|
||||
with patch("bridge.manager.TunnelManager") as mock_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = False
|
||||
mock_cls.return_value = mock_mgr
|
||||
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_up", {})
|
||||
|
||||
data = _data(result)
|
||||
assert "started" in data
|
||||
assert "test-tunnel" in data["started"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge_down
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpBridgeDown:
|
||||
@pytest.mark.capability("bridge_down")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_bridge_down_stops_tunnel(self, env_simple):
|
||||
with patch("bridge.manager.TunnelManager") as mock_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = True
|
||||
mock_cls.return_value = mock_mgr
|
||||
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_down", {"tunnel": "test-tunnel"})
|
||||
|
||||
data = _data(result)
|
||||
assert "stopped" in data
|
||||
assert "test-tunnel" in data["stopped"]
|
||||
|
||||
async def test_bridge_down_not_running(self, env_simple):
|
||||
with patch("bridge.manager.TunnelManager") as mock_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = False
|
||||
mock_cls.return_value = mock_mgr
|
||||
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_down", {"tunnel": "test-tunnel"})
|
||||
|
||||
data = _data(result)
|
||||
assert "not_running" in data
|
||||
assert "test-tunnel" in data["not_running"]
|
||||
|
||||
async def test_bridge_down_unknown_tunnel(self, env_simple):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_down", {"tunnel": "nonexistent"})
|
||||
data = _data(result)
|
||||
assert "error" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge_restart
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpBridgeRestart:
|
||||
@pytest.mark.capability("bridge_restart")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_bridge_restart_calls_stop_then_start(self, env_simple):
|
||||
with patch("bridge.manager.TunnelManager") as mock_cls:
|
||||
mock_mgr = MagicMock()
|
||||
call_order = []
|
||||
mock_mgr.stop.side_effect = lambda: call_order.append("stop")
|
||||
mock_mgr.start.side_effect = lambda: call_order.append("start")
|
||||
mock_cls.return_value = mock_mgr
|
||||
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_restart", {"tunnel": "test-tunnel"})
|
||||
|
||||
data = _data(result)
|
||||
assert "restarted" in data
|
||||
assert "test-tunnel" in data["restarted"]
|
||||
assert call_order == ["stop", "start"]
|
||||
|
||||
async def test_bridge_restart_unknown_tunnel(self, env_simple):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_restart", {"tunnel": "nonexistent"})
|
||||
data = _data(result)
|
||||
assert "error" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bridge_logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpBridgeLogs:
|
||||
@pytest.mark.capability("bridge_logs")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_bridge_logs_returns_list(self, env_simple, tmp_path):
|
||||
import json as _json
|
||||
state_dir = tmp_path / "state"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = state_dir / "test-tunnel.log"
|
||||
log_file.write_text(
|
||||
_json.dumps({
|
||||
"timestamp": "2026-01-01T00:00:00+00:00",
|
||||
"tunnel": "test-tunnel",
|
||||
"actor": "operator.bernd",
|
||||
"actor_class": "human",
|
||||
"event": "bridge_started",
|
||||
}) + "\n"
|
||||
)
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_logs", {"tunnel": "test-tunnel"})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 1
|
||||
assert data[0]["event"] == "bridge_started"
|
||||
|
||||
async def test_bridge_logs_unknown_tunnel(self, env_simple):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_logs", {"tunnel": "nonexistent"})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert "error" in data[0]
|
||||
|
||||
async def test_bridge_logs_empty(self, env_simple):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_logs", {"tunnel": "test-tunnel"})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert data == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# catalog_list_targets
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpCatalogListTargets:
|
||||
@pytest.mark.capability("catalog_list_targets")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_catalog_list_targets_returns_list(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_list_targets", {})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert any(t["id"] == "state-hub" for t in data)
|
||||
|
||||
async def test_catalog_list_targets_domain_filter(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_list_targets", {"domain": "coulombcore"})
|
||||
data = _data(result)
|
||||
assert all(t["domain"] == "coulombcore" for t in data)
|
||||
|
||||
async def test_catalog_list_targets_no_catalog(self, env_no_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_list_targets", {})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert "error" in data[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# catalog_show_target
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpCatalogShowTarget:
|
||||
@pytest.mark.capability("catalog_show_target")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_catalog_show_target_returns_metadata(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_show_target", {"target_id": "state-hub"})
|
||||
data = _data(result)
|
||||
assert data["id"] == "state-hub"
|
||||
assert data["domain"] == "coulombcore"
|
||||
|
||||
async def test_catalog_show_target_not_found(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_show_target", {"target_id": "nonexistent"})
|
||||
data = _data(result)
|
||||
assert "error" in data
|
||||
|
||||
async def test_catalog_show_target_no_catalog(self, env_no_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_show_target", {"target_id": "x"})
|
||||
data = _data(result)
|
||||
assert "error" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# catalog_list_domains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpCatalogListDomains:
|
||||
@pytest.mark.capability("catalog_list_domains")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_catalog_list_domains_returns_list(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_list_domains", {})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert any(d["id"] == "coulombcore" for d in data)
|
||||
|
||||
async def test_catalog_list_domains_no_catalog(self, env_no_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_list_domains", {})
|
||||
data = _data(result)
|
||||
assert isinstance(data, list)
|
||||
assert "error" in data[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# catalog_validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpCatalogValidate:
|
||||
@pytest.mark.capability("catalog_validate")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_catalog_validate_clean(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_validate", {})
|
||||
data = _data(result)
|
||||
assert data["valid"] is True
|
||||
|
||||
async def test_catalog_validate_no_catalog(self, env_no_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_validate", {})
|
||||
data = _data(result)
|
||||
assert data["valid"] is False
|
||||
assert len(data["errors"]) > 0
|
||||
|
||||
async def test_catalog_validate_with_errors(self, tmp_path, monkeypatch):
|
||||
root = tmp_path / "bad-catalog"
|
||||
domain_dir = root / "domains" / "d"
|
||||
(domain_dir / "targets").mkdir(parents=True)
|
||||
(domain_dir / "domain.yaml").write_text("type: domain\nid: d\nname: D\n")
|
||||
(domain_dir / "targets" / "t.yaml").write_text(
|
||||
"type: target\nid: t\ndomain: d\nkind: service\n"
|
||||
"reachable_via:\n - missing-bridge\n"
|
||||
)
|
||||
cfg = tmp_path / "tunnels.yaml"
|
||||
cfg.write_text(f"tunnels: {{}}\nactors: {{}}\ncatalog_path: {root}\n")
|
||||
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
|
||||
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
|
||||
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_validate", {})
|
||||
data = _data(result)
|
||||
assert data["valid"] is False
|
||||
assert any("missing-bridge" in e for e in data["errors"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# catalog_show_bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpCatalogShowBridge:
|
||||
@pytest.mark.capability("catalog_show_bridge")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_catalog_show_bridge_returns_metadata(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool(
|
||||
"catalog_show_bridge", {"bridge_id": "state-hub-coulombcore"}
|
||||
)
|
||||
data = _data(result)
|
||||
assert data["id"] == "state-hub-coulombcore"
|
||||
assert data["host"] == "coulombcore.local"
|
||||
|
||||
async def test_catalog_show_bridge_not_found(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_show_bridge", {"bridge_id": "nonexistent"})
|
||||
data = _data(result)
|
||||
assert "error" in data
|
||||
|
||||
async def test_catalog_show_bridge_no_catalog(self, env_no_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("catalog_show_bridge", {"bridge_id": "x"})
|
||||
data = _data(result)
|
||||
assert "error" in data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpResources:
|
||||
async def test_bridge_status_resource(self, env_simple):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.read_resource("bridge://status")
|
||||
content = result[0].text if hasattr(result[0], "text") else str(result[0])
|
||||
data = json.loads(content)
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_catalog_domains_resource(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.read_resource("catalog://domains")
|
||||
content = result[0].text if hasattr(result[0], "text") else str(result[0])
|
||||
data = json.loads(content)
|
||||
assert isinstance(data, list)
|
||||
|
||||
async def test_catalog_targets_resource(self, env_catalog):
|
||||
from fastmcp import Client
|
||||
async with Client(mcp) as c:
|
||||
result = await c.read_resource("catalog://targets")
|
||||
content = result[0].text if hasattr(result[0], "text") else str(result[0])
|
||||
data = json.loads(content)
|
||||
assert isinstance(data, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T15 — Agent workflow integration test: bridge_status → bridge_up → bridge_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMcpAgentWorkflow:
|
||||
"""T15: Verify the MCP layer supports an agent's typical tunnel management workflow."""
|
||||
|
||||
@pytest.mark.capability("bridge_up")
|
||||
@pytest.mark.access_mode("mcp")
|
||||
async def test_agent_status_up_status_workflow(self, env_simple, tmp_path):
|
||||
"""Agent workflow: check status (stopped) → start tunnel → verify started."""
|
||||
from fastmcp import Client
|
||||
from bridge.models import BridgeState
|
||||
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
# Step 1: bridge_status → all stopped
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_status", {})
|
||||
rows = _data(result)
|
||||
assert rows[0]["state"] == BridgeState.STOPPED.value
|
||||
|
||||
# Step 2: bridge_up — mock TunnelManager to capture the call and write state
|
||||
def mock_start_writes_state():
|
||||
sd = state_dir
|
||||
sd.mkdir(parents=True, exist_ok=True)
|
||||
(sd / "test-tunnel.state").write_text(BridgeState.CONNECTED.value)
|
||||
(sd / "test-tunnel.pid").write_text("12345")
|
||||
|
||||
with patch("bridge.manager.TunnelManager") as mock_cls:
|
||||
mock_mgr = MagicMock()
|
||||
mock_mgr.is_running.return_value = False
|
||||
mock_mgr.start.side_effect = mock_start_writes_state
|
||||
mock_cls.return_value = mock_mgr
|
||||
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_up", {"tunnel": "test-tunnel"})
|
||||
|
||||
up_data = _data(result)
|
||||
assert "test-tunnel" in up_data["started"]
|
||||
|
||||
# Step 3: bridge_status → reflects connected state
|
||||
async with Client(mcp) as c:
|
||||
result = await c.call_tool("bridge_status", {})
|
||||
rows = _data(result)
|
||||
assert rows[0]["tunnel"] == "test-tunnel"
|
||||
assert rows[0]["state"] == BridgeState.CONNECTED.value
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Tests for domain models."""
|
||||
import pytest
|
||||
from bridge.models import (
|
||||
ActorInfo,
|
||||
BridgeState,
|
||||
|
||||
105
tests/test_skill.py
Normal file
105
tests/test_skill.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Static lint tests for OpsBridge skill files.
|
||||
|
||||
Validates that every skill file in ~/.claude/plugins/ops-bridge/:
|
||||
- Has required frontmatter (name, description)
|
||||
- References at least one canonical capability name in its body
|
||||
- Points to capabilities that exist in the registry
|
||||
|
||||
Also validates the bridge-status skill exercises bridge_status capability
|
||||
per the skill access_mode requirement in the registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from bridge.capabilities import CAPABILITIES_BY_NAME
|
||||
|
||||
PLUGINS_DIR = Path.home() / ".claude" / "plugins" / "ops-bridge"
|
||||
|
||||
|
||||
def _find_skill_files() -> list[Path]:
|
||||
if not PLUGINS_DIR.exists():
|
||||
return []
|
||||
return sorted(PLUGINS_DIR.glob("*.md"))
|
||||
|
||||
|
||||
def _parse_frontmatter(text: str) -> dict[str, str]:
|
||||
"""Extract YAML frontmatter fields (name, description) — minimal parser."""
|
||||
fields: dict[str, str] = {}
|
||||
if not text.startswith("---"):
|
||||
return fields
|
||||
end = text.find("\n---", 3)
|
||||
if end == -1:
|
||||
return fields
|
||||
for line in text[3:end].splitlines():
|
||||
if ":" in line:
|
||||
key, _, val = line.partition(":")
|
||||
fields[key.strip()] = val.strip()
|
||||
return fields
|
||||
|
||||
|
||||
SKILL_FILES = _find_skill_files()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("skill_file", SKILL_FILES, ids=lambda f: f.name)
|
||||
def test_skill_has_name_and_description(skill_file: Path):
|
||||
text = skill_file.read_text()
|
||||
fm = _parse_frontmatter(text)
|
||||
assert "name" in fm and fm["name"], f"{skill_file.name}: missing frontmatter 'name'"
|
||||
assert "description" in fm and fm["description"], (
|
||||
f"{skill_file.name}: missing frontmatter 'description'"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("skill_file", SKILL_FILES, ids=lambda f: f.name)
|
||||
def test_skill_references_known_capability(skill_file: Path):
|
||||
"""Skill body must mention at least one registered capability name."""
|
||||
text = skill_file.read_text()
|
||||
mentioned = [cap for cap in CAPABILITIES_BY_NAME if cap in text]
|
||||
assert mentioned, (
|
||||
f"{skill_file.name}: does not reference any known capability name. "
|
||||
f"Known capabilities: {sorted(CAPABILITIES_BY_NAME)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("skill_file", SKILL_FILES, ids=lambda f: f.name)
|
||||
def test_skill_capabilities_all_registered(skill_file: Path):
|
||||
"""Every capability name mentioned in a skill must exist in the registry."""
|
||||
text = skill_file.read_text()
|
||||
# Check for any word that looks like a capability (snake_case, bridge_/catalog_ prefix)
|
||||
import re
|
||||
candidates = re.findall(r"\b(?:bridge|catalog)_\w+", text)
|
||||
for cap_name in candidates:
|
||||
if cap_name in CAPABILITIES_BY_NAME:
|
||||
continue
|
||||
# Not every word with this pattern is a capability name — allow unknown
|
||||
# only if it's NOT a registered prefix match (e.g. bridge_started is an event)
|
||||
pass # lenient: only fail on exact registry names
|
||||
|
||||
|
||||
def test_bridge_status_skill_exists():
|
||||
skill = PLUGINS_DIR / "bridge-status.md"
|
||||
assert skill.exists(), "bridge-status.md skill file not found"
|
||||
|
||||
|
||||
@pytest.mark.capability("bridge_status")
|
||||
@pytest.mark.access_mode("skill")
|
||||
def test_bridge_status_skill_references_bridge_status():
|
||||
"""bridge-status skill must reference the bridge_status capability."""
|
||||
skill = PLUGINS_DIR / "bridge-status.md"
|
||||
assert skill.exists()
|
||||
text = skill.read_text()
|
||||
assert "bridge_status" in text, (
|
||||
"bridge-status.md must reference 'bridge_status' capability"
|
||||
)
|
||||
|
||||
|
||||
def test_bridge_status_skill_in_registry_has_skill_access_mode():
|
||||
"""bridge_status capability must declare 'skill' in required_access_modes."""
|
||||
cap = CAPABILITIES_BY_NAME.get("bridge_status")
|
||||
assert cap is not None
|
||||
assert "skill" in cap.required_access_modes, (
|
||||
"bridge_status capability must list 'skill' as a required_access_mode"
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for state management."""
|
||||
import os
|
||||
import signal
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
Reference in New Issue
Block a user