Skip to content
This repository was archived by the owner on Jul 13, 2026. It is now read-only.

Commit 288620d

Browse files
authored
✅ Initial integration tests (#89)
* ✅ Initial integration tests Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> * 💡 Update test comments on CI Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> * ⏪ Put back CI comment Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> * 🔧 Isort config Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> * ♻️ Separate integration tests Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com> --------- Signed-off-by: Evaline Ju <69598118+evaline-ju@users.noreply.github.com>
1 parent ccb0fdb commit 288620d

9 files changed

Lines changed: 386 additions & 4 deletions

File tree

.github/workflows/ci.yaml

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ permissions:
1010
contents: read
1111

1212
jobs:
13-
build:
13+
lint-and-unit-tests:
14+
name: Lint & Unit Tests
1415
runs-on: ubuntu-latest
1516
timeout-minutes: 15
1617

@@ -58,10 +59,44 @@ jobs:
5859
working-directory: ./plugins/examples/nemocheck
5960
run: uv run pytest tests
6061

61-
# Server tests
62+
# Server unit tests (no proto generation needed — envoy modules are mocked)
6263
- name: Install server test dependencies
6364
run: uv sync --group dev
6465
- name: Run server unit tests
6566
run: |
6667
echo "Running server unit tests..."
67-
uv run pytest tests
68+
uv run pytest tests/ --ignore=tests/integration
69+
70+
integration-tests:
71+
name: Integration Tests
72+
runs-on: ubuntu-latest
73+
timeout-minutes: 15
74+
needs: lint-and-unit-tests
75+
76+
steps:
77+
- name: Checkout repository
78+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
79+
80+
- name: Set up Python 3.11
81+
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
82+
with:
83+
python-version: "3.11"
84+
85+
- name: Install uv
86+
run: pip install uv
87+
88+
# Build generated protos (gitignored, needed for real envoy imports)
89+
- name: Build protobuf files
90+
run: |
91+
uv sync --group proto
92+
USE_HTTPS=true ./proto-build.sh
93+
94+
- name: Install test dependencies
95+
run: uv sync --group dev
96+
97+
- name: Run integration tests
98+
env:
99+
PYTHONPATH: src
100+
run: |
101+
echo "Running integration tests..."
102+
uv run pytest tests/integration/ -v

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ exclude = [
3939
[tool.ruff.lint]
4040
select = ["E", "F", "I", "W"]
4141

42+
[tool.ruff.lint.isort]
43+
known-first-party = ["src", "tests"]
44+
known-third-party = ["cpex", "envoy", "grpc", "google"]
45+
4246
[tool.pytest.ini_options]
4347
log_cli = false
4448
log_cli_level = "INFO"
@@ -49,6 +53,9 @@ log_format = "%(asctime)s [%(module)s] [%(levelname)s] %(message)s"
4953
log_date_format = "%Y-%m-%d %H:%M:%S"
5054
testpaths = ["tests"]
5155
pythonpath = [".", "src"]
56+
markers = [
57+
"integration: integration tests (start real gRPC server)",
58+
]
5259
filterwarnings = [
5360
"ignore::DeprecationWarning",
5461
]

tests/pytest.ini renamed to pytest.ini

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ log_cli_date_format = %Y-%m-%d %H:%M:%S
66
log_level = INFO
77
log_format = %(asctime)s [%(module)s] [%(levelname)s] %(message)s
88
log_date_format = %Y-%m-%d %H:%M:%S
9-
pythonpath = . src
9+
# Paths relative to rootdir (repo root).
10+
# tests = for `from conftest import ...` in unit tests
11+
# src = for generated envoy/xds protos used by integration tests
12+
pythonpath = tests src
13+
testpaths = tests
14+
markers =
15+
integration: integration tests (start real gRPC server)
1016
filterwarnings =
1117
ignore::DeprecationWarning

tests/integration/__init__.py

Whitespace-only changes.

tests/integration/config.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
plugins:
2+
- name: "PassthroughPlugin"
3+
kind: "tests.integration.passthrough_plugin.plugin.PassthroughPlugin"
4+
description: "Passthrough plugin for integration testing"
5+
version: "0.1.0"
6+
hooks: ["tool_pre_invoke", "tool_post_invoke"]
7+
mode: "sequential"
8+
config: {}
9+
10+
plugin_dirs:
11+
- "tests/integration/passthrough_plugin"
12+
13+
plugin_settings:
14+
parallel_execution_within_band: false
15+
plugin_timeout: 10
16+
fail_on_plugin_error: true
17+
enable_plugin_api: false

tests/integration/conftest.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""Fixtures for integration tests — starts a real gRPC ext-proc server.
2+
3+
Uses module-scoped state so the server starts once per test module on the
4+
first test's event loop, then reuses for subsequent tests.
5+
"""
6+
7+
import os
8+
import pathlib
9+
10+
import grpc
11+
import pytest_asyncio
12+
from cpex.framework import PluginManager
13+
from envoy.service.ext_proc.v3 import external_processor_pb2_grpc as ep_grpc
14+
15+
INTEGRATION_DIR = pathlib.Path(__file__).parent
16+
CONFIG_PATH = str(INTEGRATION_DIR / "config.yaml")
17+
18+
19+
@pytest_asyncio.fixture
20+
async def grpc_stub():
21+
"""Start a gRPC server and yield a connected stub, then tear down."""
22+
import src.server as server_module
23+
24+
os.environ["PLUGIN_MANAGER_CONFIG"] = CONFIG_PATH
25+
manager = PluginManager(CONFIG_PATH)
26+
await manager.initialize()
27+
server_module.manager = manager
28+
29+
server = grpc.aio.server()
30+
ep_grpc.add_ExternalProcessorServicer_to_server(server_module.ExtProcServicer(), server)
31+
port = server.add_insecure_port("127.0.0.1:0")
32+
await server.start()
33+
34+
channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}")
35+
stub = ep_grpc.ExternalProcessorStub(channel)
36+
37+
yield stub
38+
39+
await channel.close()
40+
await server.stop(grace=1)
41+
await manager.shutdown()

tests/integration/passthrough_plugin/__init__.py

Whitespace-only changes.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Passthrough test plugin for integration testing.
2+
3+
A minimal cpex Plugin that either passes through or blocks requests
4+
based on a class-level toggle, allowing tests to control behavior.
5+
"""
6+
7+
import logging
8+
9+
from cpex.framework import (
10+
Plugin,
11+
PluginConfig,
12+
PluginContext,
13+
PluginViolation,
14+
ToolPostInvokePayload,
15+
ToolPostInvokeResult,
16+
ToolPreInvokePayload,
17+
ToolPreInvokeResult,
18+
)
19+
20+
logger = logging.getLogger(__name__)
21+
22+
23+
class PassthroughPlugin(Plugin):
24+
"""Test plugin that can be toggled between passthrough and blocking mode."""
25+
26+
# Class-level toggles so tests can control behavior
27+
block_pre_invoke = False
28+
block_post_invoke = False
29+
30+
def __init__(self, config: PluginConfig):
31+
super().__init__(config)
32+
33+
@classmethod
34+
def reset(cls):
35+
"""Reset toggles to default passthrough mode."""
36+
cls.block_pre_invoke = False
37+
cls.block_post_invoke = False
38+
39+
async def tool_pre_invoke(self, payload: ToolPreInvokePayload, context: PluginContext) -> ToolPreInvokeResult:
40+
if self.block_pre_invoke:
41+
violation = PluginViolation(
42+
reason="Blocked by test",
43+
description="Pre-invoke blocked for testing",
44+
code="TEST_BLOCKED",
45+
mcp_error_code=-32602,
46+
)
47+
return ToolPreInvokeResult(continue_processing=False, violation=violation)
48+
return ToolPreInvokeResult(continue_processing=True)
49+
50+
async def tool_post_invoke(self, payload: ToolPostInvokePayload, context: PluginContext) -> ToolPostInvokeResult:
51+
if self.block_post_invoke:
52+
violation = PluginViolation(
53+
reason="Blocked by test",
54+
description="Post-invoke blocked for testing",
55+
code="TEST_BLOCKED",
56+
mcp_error_code=-32603,
57+
)
58+
return ToolPostInvokeResult(continue_processing=False, violation=violation)
59+
return ToolPostInvokeResult(continue_processing=True)

0 commit comments

Comments
 (0)