Skip to content

Commit 11eefaa

Browse files
committed
Snapshot record then reply testing
1 parent 8741728 commit 11eefaa

64 files changed

Lines changed: 13417 additions & 17 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: 5 additions & 5 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

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: 130 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,121 @@ 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_path = os.path.join(
69+
test_dir, "snapshots", test_file, f"{fn.__qualname__}.json"
70+
)
71+
72+
# TODO: DROP THIS, and use the dir in VCR.
73+
os.makedirs(os.path.dirname(snapshot_path), exist_ok=True)
74+
75+
@functools.wraps(fn)
76+
async def wrapper(self: AITestCase, *args: Any, **kwargs: Any) -> None:
77+
settings = self.test_llm_settings
78+
assert settings.internal_ai is not None
79+
80+
internal_ai_hostname = parse.urlparse(
81+
settings.internal_ai.base_url
82+
).hostname
83+
84+
REDACTED_APP_KEY = "[[[--APPKEY-REDACTED-]]]"
85+
86+
class _JSONFriendlySerializer:
87+
def deserialize(self, serialized: str) -> Any:
88+
assert settings.internal_ai is not None
89+
serialized = serialized.replace(
90+
REDACTED_APP_KEY, settings.internal_ai.app_key
91+
)
92+
93+
data = json.loads(serialized)
94+
for interaction in data.get("interactions", []):
95+
interaction["request"]["uri"] = interaction["request"][
96+
"uri"
97+
].replace("internal-ai-host", internal_ai_hostname, 1)
98+
99+
interaction["request"]["body"] = json.dumps(
100+
interaction["request"]["body"]
101+
)
102+
body = interaction["response"]["body"]
103+
interaction["response"]["body"] = {}
104+
interaction["response"]["body"]["string"] = json.dumps(body)
105+
106+
return data
107+
108+
def serialize(self, dict: Any) -> str:
109+
for interaction in dict.get("interactions", []):
110+
interaction["request"]["uri"] = interaction["request"][
111+
"uri"
112+
].replace(internal_ai_hostname, "internal-ai-host", 1)
113+
114+
body = interaction["request"]["body"]
115+
interaction["request"]["body"] = json.loads(body)
116+
117+
resp_body = interaction["response"]["body"]["string"]
118+
interaction["response"]["body"] = json.loads(resp_body)
119+
120+
# TODO: redact app_key with [KEY-REDACTED-KAJFKDJFKSDJKFUSFIUIUR]
121+
# TODO: assert that nothing from settings is in the dump.
122+
out = json.dumps(dict, indent=4) + "\n"
123+
assert settings.internal_ai is not None
124+
out = out.replace(settings.internal_ai.app_key, REDACTED_APP_KEY)
125+
126+
# Assert
127+
128+
return out
129+
130+
def _before_record_request(request: Request) -> Request | None:
131+
url = parse.urlparse(request.uri)
132+
if url.hostname == internal_ai_hostname:
133+
request.headers = {}
134+
return request
135+
return None
136+
137+
def _before_record_response(response: Any) -> Any:
138+
response["headers"] = {}
139+
return response
140+
141+
def _json_body_matcher(r1: Any, r2: Any) -> None:
142+
b1 = json.loads(r1.body)
143+
b2 = json.loads(r2.body)
144+
if b1 != b2:
145+
raise AssertionError(f"Body mismatch:\n{b1}\n!=\n{b2}")
146+
147+
my_vcr = vcr.VCR(
148+
cassette_library_dir=".",
149+
serializer="json-friendly",
150+
record_mode=RecordMode.ONCE,
151+
match_on=[
152+
"method",
153+
"scheme",
154+
"host",
155+
"port",
156+
"path",
157+
"query",
158+
"jsonbody",
159+
],
160+
before_record_request=_before_record_request,
161+
before_record_response=_before_record_response,
162+
# record_on_exception=False,
163+
drop_unused_requests=True,
164+
)
165+
my_vcr.register_serializer("json-friendly", _JSONFriendlySerializer())
166+
my_vcr.register_matcher("jsonbody", _json_body_matcher)
167+
168+
with my_vcr.use_cassette(snapshot_path):
169+
await fn(self, *args, **kwargs)
170+
171+
return wrapper
172+
173+
return decorator

0 commit comments

Comments
 (0)