Skip to content

Commit bc58899

Browse files
mjnoviceclaude
andcommitted
test: add E2E tests for MemoryService against real ECS + LLMOps
Full lifecycle test: create index → create feedback → ingest via LLMOps → search → verify response shape → delete index. Tests are marked @pytest.mark.e2e and excluded from default runs. Run with: uv run pytest -m e2e -v (requires UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY env vars). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0180323 commit bc58899

2 files changed

Lines changed: 265 additions & 1 deletion

File tree

packages/uipath-platform/pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,12 @@ warn_required_dynamic_aliases = true
9898
[tool.pytest.ini_options]
9999
testpaths = ["tests"]
100100
python_files = "test_*.py"
101-
addopts = "-ra -q --cov=src/uipath --cov-report=term-missing"
101+
addopts = "-ra -q --cov=src/uipath --cov-report=term-missing -m 'not e2e'"
102102
asyncio_default_fixture_loop_scope = "function"
103103
asyncio_mode = "auto"
104+
markers = [
105+
"e2e: end-to-end tests against real ECS/LLMOps (requires UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY)",
106+
]
104107

105108
[tool.coverage.report]
106109
show_missing = true
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
"""E2E tests for MemoryService against real ECS + LLMOps endpoints.
2+
3+
Prerequisites:
4+
uipath auth --alpha # sets UIPATH_URL + UIPATH_ACCESS_TOKEN
5+
export UIPATH_FOLDER_KEY=... # folder GUID with agent memory enabled
6+
7+
Run:
8+
cd packages/uipath-platform
9+
uv run pytest tests/services/test_memory_service_e2e.py -m e2e -v
10+
"""
11+
12+
import os
13+
import uuid
14+
15+
import httpx
16+
import pytest
17+
18+
from uipath.platform import UiPath
19+
from uipath.platform.errors import EnrichedException
20+
from uipath.platform.memory import (
21+
EpisodicMemoryIndex,
22+
EpisodicMemoryListResponse,
23+
MemoryIngestResponse,
24+
MemorySearchRequest,
25+
MemorySearchResponse,
26+
SearchField,
27+
SearchMode,
28+
SearchSettings,
29+
)
30+
31+
pytestmark = pytest.mark.e2e
32+
33+
34+
def _require_env(name: str) -> str:
35+
value = os.environ.get(name)
36+
if not value:
37+
pytest.skip(f"Environment variable {name} is not set")
38+
return value
39+
40+
41+
@pytest.fixture(scope="module")
42+
def sdk() -> UiPath:
43+
"""Create a real UiPath client from env vars."""
44+
_require_env("UIPATH_URL")
45+
_require_env("UIPATH_ACCESS_TOKEN")
46+
return UiPath()
47+
48+
49+
@pytest.fixture(scope="module")
50+
def folder_key() -> str:
51+
return _require_env("UIPATH_FOLDER_KEY")
52+
53+
54+
@pytest.fixture(scope="module")
55+
def base_url() -> str:
56+
return _require_env("UIPATH_URL")
57+
58+
59+
@pytest.fixture(scope="module")
60+
def access_token() -> str:
61+
return _require_env("UIPATH_ACCESS_TOKEN")
62+
63+
64+
@pytest.fixture(scope="module")
65+
def memory_index(sdk: UiPath, folder_key: str): # noqa: ANN201
66+
"""Create a test memory index and clean it up after all tests."""
67+
unique_name = f"sdk-e2e-test-{uuid.uuid4().hex[:8]}"
68+
index = sdk.memory.create(
69+
name=unique_name,
70+
description="Created by E2E test — safe to delete",
71+
folder_key=folder_key,
72+
)
73+
yield index
74+
# Cleanup
75+
try:
76+
sdk.memory.delete_index(key=index.id, folder_key=folder_key)
77+
except Exception:
78+
pass
79+
80+
81+
class TestMemoryServiceE2E:
82+
"""E2E tests for MemoryService lifecycle.
83+
84+
Requires: UIPATH_URL, UIPATH_ACCESS_TOKEN, UIPATH_FOLDER_KEY
85+
"""
86+
87+
# ── Index CRUD (ECS) ──────────────────────────────────────────
88+
89+
def test_create_index(self, memory_index: EpisodicMemoryIndex) -> None:
90+
"""Verify index creation returns a well-formed EpisodicMemoryIndex."""
91+
assert memory_index.id, "Index ID should be set"
92+
assert memory_index.name.startswith("sdk-e2e-test-")
93+
assert memory_index.folder_key, "Folder key should be populated"
94+
assert memory_index.memories_count == 0
95+
96+
def test_get_index(
97+
self,
98+
sdk: UiPath,
99+
memory_index: EpisodicMemoryIndex,
100+
folder_key: str,
101+
) -> None:
102+
"""Verify we can retrieve the index by key."""
103+
fetched = sdk.memory.get(key=memory_index.id, folder_key=folder_key)
104+
assert fetched.id == memory_index.id
105+
assert fetched.name == memory_index.name
106+
107+
def test_list_indexes(
108+
self,
109+
sdk: UiPath,
110+
memory_index: EpisodicMemoryIndex,
111+
folder_key: str,
112+
) -> None:
113+
"""Verify list with OData filter returns our index."""
114+
result = sdk.memory.list(
115+
filter=f"Name eq '{memory_index.name}'",
116+
folder_key=folder_key,
117+
)
118+
assert isinstance(result, EpisodicMemoryListResponse)
119+
names = [idx.name for idx in result.value]
120+
assert memory_index.name in names
121+
122+
# ── Search (LLMOps) ──────────────────────────────────────────
123+
124+
def test_search_empty_index(
125+
self,
126+
sdk: UiPath,
127+
memory_index: EpisodicMemoryIndex,
128+
folder_key: str,
129+
) -> None:
130+
"""Search an empty index — should return empty results and systemPromptInjection."""
131+
request = MemorySearchRequest(
132+
fields=[
133+
SearchField(
134+
key_path=["input"],
135+
value="test query",
136+
)
137+
],
138+
settings=SearchSettings(
139+
threshold=0.0,
140+
result_count=5,
141+
search_mode=SearchMode.Hybrid,
142+
),
143+
definition_system_prompt="You are a helpful assistant.",
144+
)
145+
result = sdk.memory.search(
146+
memory_space_id=memory_index.id,
147+
request=request,
148+
folder_key=folder_key,
149+
)
150+
assert isinstance(result, MemorySearchResponse)
151+
assert isinstance(result.results, list)
152+
assert isinstance(result.metadata, dict)
153+
# systemPromptInjection should be a string (possibly empty for no results)
154+
assert isinstance(result.system_prompt_injection, str)
155+
156+
# ── Full ingest lifecycle (LLMOps) ────────────────────────────
157+
158+
def test_ingest_and_search(
159+
self,
160+
sdk: UiPath,
161+
memory_index: EpisodicMemoryIndex,
162+
folder_key: str,
163+
base_url: str,
164+
access_token: str,
165+
) -> None:
166+
"""Full lifecycle: create feedback → ingest → search → verify match."""
167+
# Step 1: Create a synthetic feedback via LLMOps API directly
168+
# (MemoryService doesn't have a feedback API — this is test scaffolding)
169+
trace_id = str(uuid.uuid4())
170+
span_id = str(uuid.uuid4())
171+
user_id = str(uuid.uuid4())
172+
173+
feedback_payload = {
174+
"traceId": trace_id,
175+
"spanId": span_id,
176+
"userId": user_id,
177+
"isPositive": True,
178+
"isOutput": False,
179+
"isAgentError": False,
180+
"isAgentPlanExecution": False,
181+
"memorySpaceId": memory_index.id,
182+
"memorySpaceName": memory_index.name,
183+
"attributes": '{"input": "What is the capital of France?", "output": "Paris"}',
184+
}
185+
186+
with httpx.Client(
187+
base_url=base_url,
188+
headers={"Authorization": f"Bearer {access_token}"},
189+
timeout=30.0,
190+
) as client:
191+
resp = client.post(
192+
"/llmops_/api/Agent/feedback",
193+
json=feedback_payload,
194+
)
195+
# If feedback creation fails (e.g. LLMOps not available),
196+
# skip gracefully rather than fail the whole suite
197+
if resp.status_code >= 400:
198+
pytest.skip(
199+
f"Could not create feedback (HTTP {resp.status_code}): {resp.text}"
200+
)
201+
feedback_data = resp.json()
202+
203+
feedback_id = feedback_data.get("id") or feedback_data.get("feedbackId")
204+
assert feedback_id, f"No feedback ID in response: {feedback_data}"
205+
206+
# Step 2: Ingest via MemoryService (LLMOps)
207+
ingest_result = sdk.memory.ingest(
208+
memory_space_id=memory_index.id,
209+
feedback_id=feedback_id,
210+
memory_space_name=memory_index.name,
211+
folder_key=folder_key,
212+
)
213+
assert isinstance(ingest_result, MemoryIngestResponse)
214+
assert ingest_result.memory_item_id, "Should return a memory item ID"
215+
216+
# Step 3: Search to find the ingested memory
217+
search_request = MemorySearchRequest(
218+
fields=[
219+
SearchField(
220+
key_path=["input"],
221+
value="What is the capital of France?",
222+
)
223+
],
224+
settings=SearchSettings(
225+
threshold=0.0,
226+
result_count=5,
227+
search_mode=SearchMode.Hybrid,
228+
),
229+
definition_system_prompt="You are a helpful assistant.",
230+
)
231+
search_result = sdk.memory.search(
232+
memory_space_id=memory_index.id,
233+
request=search_request,
234+
folder_key=folder_key,
235+
)
236+
assert isinstance(search_result, MemorySearchResponse)
237+
assert isinstance(search_result.system_prompt_injection, str)
238+
# Ingestion may be async — we verify the response shape is valid
239+
# even if results aren't immediately available
240+
assert isinstance(search_result.results, list)
241+
242+
# ── Delete lifecycle ──────────────────────────────────────────
243+
244+
def test_delete_index(
245+
self,
246+
sdk: UiPath,
247+
folder_key: str,
248+
) -> None:
249+
"""Verify index deletion works (uses a separate index to not break other tests)."""
250+
temp_name = f"sdk-e2e-delete-{uuid.uuid4().hex[:8]}"
251+
temp_index = sdk.memory.create(
252+
name=temp_name,
253+
description="Temp index for delete test",
254+
folder_key=folder_key,
255+
)
256+
# Delete it
257+
sdk.memory.delete_index(key=temp_index.id, folder_key=folder_key)
258+
259+
# Verify it's gone — GET should raise
260+
with pytest.raises(EnrichedException):
261+
sdk.memory.get(key=temp_index.id, folder_key=folder_key)

0 commit comments

Comments
 (0)