Skip to content

Commit 122ffe1

Browse files
committed
Snapshot record then reply testing
1 parent 8741728 commit 122ffe1

64 files changed

Lines changed: 12845 additions & 18 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ jobs:
1919
with:
2020
python-version: ${{ matrix.python-version }}
2121
deps-group: test
22-
- name: Download Splunk MCP Server App
23-
run: uv run ./scripts/download_splunk_mcp_server_app.py
24-
env:
25-
SPLUNKBASE_USERNAME: ${{ secrets.SPLUNKBASE_USERNAME }}
26-
SPLUNKBASE_PASSWORD: ${{ secrets.SPLUNKBASE_PASSWORD }}
22+
#- name: Download Splunk MCP Server App
23+
# run: uv run ./scripts/download_splunk_mcp_server_app.py
24+
# env:
25+
# SPLUNKBASE_USERNAME: ${{ secrets.SPLUNKBASE_USERNAME }}
26+
# SPLUNKBASE_PASSWORD: ${{ secrets.SPLUNKBASE_PASSWORD }}
2727
- name: Launch Splunk Docker instance
2828
run: SPLUNK_VERSION=${{ matrix.splunk-version }} docker compose up -d
2929
- name: Set up .env
@@ -52,4 +52,4 @@ jobs:
5252
- name: Run unit tests
5353
run: make test-unit
5454
- name: Run entire test suite
55-
run: make test-integration
55+
run: make test-ai

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ test = [
4545
"pytest-cov>=7.1.0",
4646
"pytest-asyncio>=1.3.0",
4747
"python-dotenv>=1.2.2",
48+
"pytest-recording>=0.13.4",
4849
]
4950
release = ["build>=1.4.3", "jinja2>=3.1.6", "sphinx>=9.1.0", "twine>=6.2.0"]
5051
lint = ["basedpyright>=1.39.0", "ruff>=0.15.10"]

tests/ai_testlib.py

Lines changed: 127 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
1-
from typing import override
1+
import functools
2+
import inspect
3+
import json
4+
import os
5+
from collections.abc import Callable, Coroutine
6+
from typing import Any, override
7+
from urllib import parse
8+
9+
import vcr
10+
from vcr.config import RecordMode
11+
from vcr.request import Request
12+
213
from splunklib.ai.model import PredefinedModel
314
from tests.ai_test_model import InternalAIModel, TestLLMSettings, create_model
415
from tests.testlib import SDKTestCase
@@ -42,3 +53,118 @@ async def model(self) -> PredefinedModel:
4253
model = await create_model(self.test_llm_settings)
4354
self._model = model
4455
return model
56+
57+
58+
def ai_snapshot_test() -> Callable[
59+
[Callable[..., Coroutine[Any, Any, None]]], Callable[..., Coroutine[Any, Any, None]]
60+
]:
61+
def decorator(
62+
fn: Callable[..., Coroutine[Any, Any, None]],
63+
) -> Callable[..., Coroutine[Any, Any, None]]:
64+
source_file = inspect.getfile(fn)
65+
test_dir = os.path.dirname(source_file)
66+
test_file = os.path.splitext(os.path.basename(source_file))[0]
67+
68+
snapshot_dir = os.path.join(test_dir, "snapshots", test_file)
69+
snapshot_filename = f"{fn.__qualname__}.json"
70+
71+
@functools.wraps(fn)
72+
async def wrapper(self: AITestCase, *args: Any, **kwargs: Any) -> None:
73+
settings = self.test_llm_settings
74+
assert settings.internal_ai is not None
75+
76+
internal_ai_hostname = parse.urlparse(
77+
settings.internal_ai.base_url
78+
).hostname
79+
80+
REDACTED_APP_KEY = "[[[--APPKEY-REDACTED-]]]"
81+
82+
class _JSONFriendlySerializer:
83+
def deserialize(self, serialized: str) -> Any:
84+
assert settings.internal_ai is not None
85+
serialized = serialized.replace(
86+
REDACTED_APP_KEY, settings.internal_ai.app_key
87+
)
88+
89+
data = json.loads(serialized)
90+
for interaction in data.get("interactions", []):
91+
interaction["request"]["uri"] = interaction["request"][
92+
"uri"
93+
].replace("internal-ai-host", internal_ai_hostname, 1)
94+
95+
interaction["request"]["body"] = json.dumps(
96+
interaction["request"]["body"]
97+
)
98+
body = interaction["response"]["body"]
99+
interaction["response"]["body"] = {}
100+
interaction["response"]["body"]["string"] = json.dumps(body)
101+
102+
return data
103+
104+
def serialize(self, dict: Any) -> str:
105+
for interaction in dict.get("interactions", []):
106+
interaction["request"]["uri"] = interaction["request"][
107+
"uri"
108+
].replace(internal_ai_hostname, "internal-ai-host", 1)
109+
110+
body = interaction["request"]["body"]
111+
interaction["request"]["body"] = json.loads(body)
112+
113+
resp_body = interaction["response"]["body"]["string"]
114+
interaction["response"]["body"] = json.loads(resp_body)
115+
116+
# TODO: redact app_key with [KEY-REDACTED-KAJFKDJFKSDJKFUSFIUIUR]
117+
# TODO: assert that nothing from settings is in the dump.
118+
out = json.dumps(dict, indent=4) + "\n"
119+
assert settings.internal_ai is not None
120+
out = out.replace(settings.internal_ai.app_key, REDACTED_APP_KEY)
121+
122+
# Assert
123+
124+
return out
125+
126+
def _before_record_request(request: Request) -> Request | None:
127+
url = parse.urlparse(request.uri)
128+
if url.hostname == internal_ai_hostname:
129+
request.headers = {}
130+
return request
131+
return None
132+
133+
def _before_record_response(response: Any) -> Any:
134+
response["headers"] = {}
135+
return response
136+
137+
def _json_body_matcher(r1: Any, r2: Any) -> None:
138+
b1 = json.loads(r1.body)
139+
b2 = json.loads(r2.body)
140+
if b1 != b2:
141+
raise AssertionError(f"Body mismatch:\n{b1}\n!=\n{b2}")
142+
143+
my_vcr = vcr.VCR(
144+
cassette_library_dir=snapshot_dir,
145+
serializer="json-friendly",
146+
# TODO: fix to ONCE?
147+
record_mode=RecordMode.NONE,
148+
match_on=[
149+
"method",
150+
"scheme",
151+
"host",
152+
"port",
153+
"path",
154+
"query",
155+
"jsonbody",
156+
],
157+
before_record_request=_before_record_request,
158+
before_record_response=_before_record_response,
159+
# record_on_exception=False,
160+
drop_unused_requests=True,
161+
)
162+
my_vcr.register_serializer("json-friendly", _JSONFriendlySerializer())
163+
my_vcr.register_matcher("jsonbody", _json_body_matcher)
164+
165+
with my_vcr.use_cassette(snapshot_filename):
166+
await fn(self, *args, **kwargs)
167+
168+
return wrapper
169+
170+
return decorator

0 commit comments

Comments
 (0)