Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ permissions:
contents: read

jobs:
build:
lint-and-unit-tests:
name: Lint & Unit Tests
runs-on: ubuntu-latest
timeout-minutes: 15

Expand Down Expand Up @@ -58,10 +59,44 @@ jobs:
working-directory: ./plugins/examples/nemocheck
run: uv run pytest tests

# Server tests
# Server unit tests (no proto generation needed — envoy modules are mocked)
- name: Install server test dependencies
run: uv sync --group dev
- name: Run server unit tests
run: |
echo "Running server unit tests..."
uv run pytest tests
uv run pytest tests/ --ignore=tests/integration

integration-tests:
name: Integration Tests
runs-on: ubuntu-latest
timeout-minutes: 15
needs: lint-and-unit-tests

steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

- name: Set up Python 3.11
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.11"

- name: Install uv
run: pip install uv

# Build generated protos (gitignored, needed for real envoy imports)
- name: Build protobuf files
run: |
uv sync --group proto
USE_HTTPS=true ./proto-build.sh

- name: Install test dependencies
run: uv sync --group dev

- name: Run integration tests
env:
PYTHONPATH: src
run: |
echo "Running integration tests..."
uv run pytest tests/integration/ -v
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ exclude = [
[tool.ruff.lint]
select = ["E", "F", "I", "W"]

[tool.ruff.lint.isort]
known-first-party = ["src", "tests"]
known-third-party = ["cpex", "envoy", "grpc", "google"]

[tool.pytest.ini_options]
log_cli = false
log_cli_level = "INFO"
Expand All @@ -49,6 +53,9 @@ log_format = "%(asctime)s [%(module)s] [%(levelname)s] %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
testpaths = ["tests"]
pythonpath = [".", "src"]
markers = [
"integration: integration tests (start real gRPC server)",
]
filterwarnings = [
"ignore::DeprecationWarning",
]
8 changes: 7 additions & 1 deletion tests/pytest.ini → pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ log_cli_date_format = %Y-%m-%d %H:%M:%S
log_level = INFO
log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s
log_date_format = %Y-%m-%d %H:%M:%S
pythonpath = . src
# Paths relative to rootdir (repo root).
# tests = for `from conftest import ...` in unit tests
# src = for generated envoy/xds protos used by integration tests
pythonpath = tests src
testpaths = tests
markers =
integration: integration tests (start real gRPC server)
filterwarnings =
ignore::DeprecationWarning
Empty file added tests/integration/__init__.py
Empty file.
17 changes: 17 additions & 0 deletions tests/integration/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
plugins:
- name: "PassthroughPlugin"
kind: "tests.integration.passthrough_plugin.plugin.PassthroughPlugin"
description: "Passthrough plugin for integration testing"
version: "0.1.0"
hooks: ["tool_pre_invoke", "tool_post_invoke"]
mode: "sequential"
config: {}

plugin_dirs:
- "tests/integration/passthrough_plugin"

plugin_settings:
parallel_execution_within_band: false
plugin_timeout: 10
fail_on_plugin_error: true
enable_plugin_api: false
41 changes: 41 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Fixtures for integration tests — starts a real gRPC ext-proc server.

Uses module-scoped state so the server starts once per test module on the
first test's event loop, then reuses for subsequent tests.
"""

import os
import pathlib

import grpc
import pytest_asyncio
from cpex.framework import PluginManager
from envoy.service.ext_proc.v3 import external_processor_pb2_grpc as ep_grpc

INTEGRATION_DIR = pathlib.Path(__file__).parent
CONFIG_PATH = str(INTEGRATION_DIR / "config.yaml")


@pytest_asyncio.fixture
async def grpc_stub():
"""Start a gRPC server and yield a connected stub, then tear down."""
import src.server as server_module

os.environ["PLUGIN_MANAGER_CONFIG"] = CONFIG_PATH
manager = PluginManager(CONFIG_PATH)
await manager.initialize()
server_module.manager = manager

server = grpc.aio.server()
ep_grpc.add_ExternalProcessorServicer_to_server(server_module.ExtProcServicer(), server)
port = server.add_insecure_port("127.0.0.1:0")
await server.start()

channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}")
stub = ep_grpc.ExternalProcessorStub(channel)

yield stub

await channel.close()
await server.stop(grace=1)
await manager.shutdown()
Empty file.
59 changes: 59 additions & 0 deletions tests/integration/passthrough_plugin/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Passthrough test plugin for integration testing.

A minimal cpex Plugin that either passes through or blocks requests
based on a class-level toggle, allowing tests to control behavior.
"""

import logging

from cpex.framework import (
Plugin,
PluginConfig,
PluginContext,
PluginViolation,
ToolPostInvokePayload,
ToolPostInvokeResult,
ToolPreInvokePayload,
ToolPreInvokeResult,
)

logger = logging.getLogger(__name__)


class PassthroughPlugin(Plugin):
"""Test plugin that can be toggled between passthrough and blocking mode."""

# Class-level toggles so tests can control behavior
block_pre_invoke = False
block_post_invoke = False

def __init__(self, config: PluginConfig):
super().__init__(config)

@classmethod
def reset(cls):
"""Reset toggles to default passthrough mode."""
cls.block_pre_invoke = False
cls.block_post_invoke = False

async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult:
if self.block_pre_invoke:
violation = PluginViolation(
reason="Blocked by test",
description="Pre-invoke blocked for testing",
code="TEST_BLOCKED",
mcp_error_code=-32602,
)
return ToolPreInvokeResult(continue_processing=False, violation=violation)
return ToolPreInvokeResult(continue_processing=True)

async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult:
if self.block_post_invoke:
violation = PluginViolation(
reason="Blocked by test",
description="Post-invoke blocked for testing",
code="TEST_BLOCKED",
mcp_error_code=-32603,
)
return ToolPostInvokeResult(continue_processing=False, violation=violation)
return ToolPostInvokeResult(continue_processing=True)
Loading
Loading